remote-tests.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  1. <?php
  2. if (php_sapi_name() != 'cli') {
  3. die('not for web');
  4. }
  5. define('HTTP_TIMEOUT', 60); // ssslllloowwwww salmon if queues are off
  6. define('INSTALLDIR', dirname(dirname(dirname(dirname(__FILE__)))));
  7. set_include_path(INSTALLDIR . '/extlib' . PATH_SEPARATOR . get_include_path());
  8. require_once 'PEAR.php';
  9. require_once 'Net/URL2.php';
  10. require_once 'HTTP/Request2.php';
  11. // ostatus test script, client-side :)
  12. class TestBase
  13. {
  14. function log($str)
  15. {
  16. $args = func_get_args();
  17. array_shift($args);
  18. $msg = vsprintf($str, $args);
  19. print $msg . "\n";
  20. }
  21. function assertEqual($a, $b)
  22. {
  23. if ($a != $b) {
  24. throw new Exception("Failed to assert equality: expected $a, got $b");
  25. }
  26. return true;
  27. }
  28. function assertNotEqual($a, $b)
  29. {
  30. if ($a == $b) {
  31. throw new Exception("Failed to assert inequality: expected not $a, got $b");
  32. }
  33. return true;
  34. }
  35. function assertTrue($a)
  36. {
  37. if (!$a) {
  38. throw new Exception("Failed to assert true: got false");
  39. }
  40. }
  41. function assertFalse($a)
  42. {
  43. if ($a) {
  44. throw new Exception("Failed to assert false: got true");
  45. }
  46. }
  47. }
  48. class OStatusTester extends TestBase
  49. {
  50. /**
  51. * @param string $a base URL of test site A (eg http://localhost/mublog)
  52. * @param string $b base URL of test site B (eg http://localhost/mublog2)
  53. * @param int $timeout HTTP timeout value (needs to be long if Salmon is slow)
  54. */
  55. function __construct($a, $b, $timeout=60) {
  56. $this->a = $a;
  57. $this->b = $b;
  58. $base = 'test' . mt_rand(1, 1000000);
  59. $this->pub = new SNTestClient($this->a, 'pub' . $base, 'pw-' . mt_rand(1, 1000000), $timeout);
  60. $this->sub = new SNTestClient($this->b, 'sub' . $base, 'pw-' . mt_rand(1, 1000000), $timeout);
  61. $this->group = 'group' . $base;
  62. }
  63. function run()
  64. {
  65. $this->setup();
  66. $methods = get_class_methods($this);
  67. foreach ($methods as $method) {
  68. if (strtolower(substr($method, 0, 4)) == 'test') {
  69. print "\n";
  70. print "== $method ==\n";
  71. call_user_func(array($this, $method));
  72. }
  73. }
  74. print "\n";
  75. $this->log("DONE!");
  76. }
  77. function setup()
  78. {
  79. $this->pub->register();
  80. $this->pub->assertRegistered();
  81. $this->sub->register();
  82. $this->sub->assertRegistered();
  83. }
  84. function testLocalPost()
  85. {
  86. $post = $this->pub->post("Local post, no subscribers yet.");
  87. $this->assertNotEqual('', $post);
  88. $post = $this->sub->post("Local post, no subscriptions yet.");
  89. $this->assertNotEqual('', $post);
  90. }
  91. /**
  92. * pub posts: @b/sub
  93. */
  94. function testMentionUrl()
  95. {
  96. $bits = parse_url($this->b);
  97. $base = $bits['host'];
  98. if (isset($bits['path'])) {
  99. $base .= $bits['path'];
  100. }
  101. $name = $this->sub->username;
  102. $post = $this->pub->post("@$base/$name should have this in home and replies");
  103. $this->sub->assertReceived($post);
  104. }
  105. function testSubscribe()
  106. {
  107. $this->assertFalse($this->sub->hasSubscription($this->pub->getProfileUri()));
  108. $this->assertFalse($this->pub->hasSubscriber($this->sub->getProfileUri()));
  109. $this->sub->subscribe($this->pub->getProfileLink());
  110. $this->assertTrue($this->sub->hasSubscription($this->pub->getProfileUri()));
  111. $this->assertTrue($this->pub->hasSubscriber($this->sub->getProfileUri()));
  112. }
  113. function testPush()
  114. {
  115. $this->assertTrue($this->sub->hasSubscription($this->pub->getProfileUri()));
  116. $this->assertTrue($this->pub->hasSubscriber($this->sub->getProfileUri()));
  117. $name = $this->sub->username;
  118. $post = $this->pub->post("Regular post, which $name should get via PuSH");
  119. $this->sub->assertReceived($post);
  120. }
  121. function testMentionSubscribee()
  122. {
  123. $this->assertTrue($this->sub->hasSubscription($this->pub->getProfileUri()));
  124. $this->assertFalse($this->pub->hasSubscription($this->sub->getProfileUri()));
  125. $name = $this->pub->username;
  126. $post = $this->sub->post("Just a quick note back to my remote subscribee @$name");
  127. $this->pub->assertReceived($post);
  128. }
  129. function testUnsubscribe()
  130. {
  131. $this->assertTrue($this->sub->hasSubscription($this->pub->getProfileUri()));
  132. $this->assertTrue($this->pub->hasSubscriber($this->sub->getProfileUri()));
  133. $this->sub->unsubscribe($this->pub->getProfileLink());
  134. $this->assertFalse($this->sub->hasSubscription($this->pub->getProfileUri()));
  135. $this->assertFalse($this->pub->hasSubscriber($this->sub->getProfileUri()));
  136. }
  137. function testCreateGroup()
  138. {
  139. $this->groupUrl = $this->pub->createGroup($this->group);
  140. $this->assertTrue(!empty($this->groupUrl));
  141. }
  142. function testJoinGroup()
  143. {
  144. #$this->assertFalse($this->sub->inGroup($this->groupUrl));
  145. $this->sub->joinGroup($this->groupUrl);
  146. #$this->assertTrue($this->sub->inGroup($this->groupUrl));
  147. }
  148. function testLocalGroupPost()
  149. {
  150. $post = $this->pub->post("Group post from local to !{$this->group}, should go out over push.");
  151. $this->assertNotEqual('', $post);
  152. $this->sub->assertReceived($post);
  153. }
  154. function testRemoteGroupPost()
  155. {
  156. $post = $this->sub->post("Group post from remote to !{$this->group}, should come in over salmon.");
  157. $this->assertNotEqual('', $post);
  158. $this->pub->assertReceived($post);
  159. }
  160. function testLeaveGroup()
  161. {
  162. #$this->assertTrue($this->sub->inGroup($this->groupUrl));
  163. $this->sub->leaveGroup($this->groupUrl);
  164. #$this->assertFalse($this->sub->inGroup($this->groupUrl));
  165. }
  166. }
  167. class SNTestClient extends TestBase
  168. {
  169. function __construct($base, $username, $password, $timeout=60)
  170. {
  171. $this->basepath = $base;
  172. $this->username = $username;
  173. $this->password = $password;
  174. $this->timeout = $timeout;
  175. $this->fullname = ucfirst($username) . ' Smith';
  176. $this->homepage = 'http://example.org/' . $username;
  177. $this->bio = 'Stub account for OStatus tests.';
  178. $this->location = 'Montreal, QC';
  179. }
  180. /**
  181. * Make a low-level web hit to this site, with authentication.
  182. * @param string $path URL fragment for something under the base path
  183. * @param array $params POST parameters to send
  184. * @param boolean $auth whether to include auth data
  185. * @return string
  186. * @throws Exception on low-level error conditions
  187. */
  188. protected function hit($path, $params=array(), $auth=false, $cookies=array())
  189. {
  190. $url = $this->basepath . '/' . $path;
  191. $http = new HTTP_Request2($url, 'POST', array('timeout' => $this->timeout));
  192. if ($auth) {
  193. $http->setAuth($this->username, $this->password, HTTP_Request2::AUTH_BASIC);
  194. }
  195. foreach ($cookies as $name => $val) {
  196. $http->addCookie($name, $val);
  197. }
  198. $http->addPostParameter($params);
  199. $response = $http->send();
  200. $code = $response->getStatus();
  201. if ($code < '200' || $code >= '400') {
  202. throw new Exception("Failed API hit to $url: $code\n" . $response->getBody());
  203. }
  204. return $response;
  205. }
  206. /**
  207. * Make a hit to a web form, without authentication but with a session.
  208. * @param string $path URL fragment relative to site base
  209. * @param string $form id of web form to pull initial parameters from
  210. * @param array $params POST parameters, will be merged with defaults in form
  211. */
  212. protected function web($path, $form, $params=array())
  213. {
  214. $url = $this->basepath . '/' . $path;
  215. $http = new HTTP_Request2($url, 'GET', array('timeout' => $this->timeout));
  216. $response = $http->send();
  217. $dom = $this->checkWeb($url, 'GET', $response);
  218. $cookies = array();
  219. foreach ($response->getCookies() as $cookie) {
  220. // @fixme check for expirations etc
  221. $cookies[$cookie['name']] = $cookie['value'];
  222. }
  223. $form = $dom->getElementById($form);
  224. if (!$form) {
  225. throw new Exception("Form $form not found on $url");
  226. }
  227. $inputs = $form->getElementsByTagName('input');
  228. foreach ($inputs as $item) {
  229. $type = $item->getAttribute('type');
  230. if ($type != 'check') {
  231. $name = $item->getAttribute('name');
  232. $val = $item->getAttribute('value');
  233. if ($name && $val && !isset($params[$name])) {
  234. $params[$name] = $val;
  235. }
  236. }
  237. }
  238. $response = $this->hit($path, $params, false, $cookies);
  239. $dom = $this->checkWeb($url, 'POST', $response);
  240. return $dom;
  241. }
  242. protected function checkWeb($url, $method, $response)
  243. {
  244. $dom = new DOMDocument();
  245. if (!$dom->loadHTML($response->getBody())) {
  246. throw new Exception("Invalid HTML from $method to $url");
  247. }
  248. $xpath = new DOMXPath($dom);
  249. $error = $xpath->query('//p[@class="error"]');
  250. if ($error && $error->length) {
  251. throw new Exception("Error on $method to $url: " .
  252. $error->item(0)->textContent);
  253. }
  254. return $dom;
  255. }
  256. protected function parseXml($path, $body)
  257. {
  258. $dom = new DOMDocument();
  259. if ($dom->loadXML($body)) {
  260. return $dom;
  261. } else {
  262. throw new Exception("Bogus XML data from $path:\n$body");
  263. }
  264. }
  265. /**
  266. * Make a hit to a REST-y XML page on the site, without authentication.
  267. * @param string $path URL fragment for something relative to base
  268. * @param array $params POST parameters to send
  269. * @return DOMDocument
  270. * @throws Exception on low-level error conditions
  271. */
  272. protected function xml($path, $params=array())
  273. {
  274. $response = $this->hit($path, $params, true);
  275. $body = $response->getBody();
  276. return $this->parseXml($path, $body);
  277. }
  278. protected function parseJson($path, $body)
  279. {
  280. $data = json_decode($body, true);
  281. if ($data !== null) {
  282. if (!empty($data['error'])) {
  283. throw new Exception("JSON API returned error: " . $data['error']);
  284. }
  285. return $data;
  286. } else {
  287. throw new Exception("Bogus JSON data from $path:\n$body");
  288. }
  289. }
  290. /**
  291. * Make an API hit to this site, with authentication.
  292. * @param string $path URL fragment for something under 'api' folder
  293. * @param string $style one of 'json', 'xml', or 'atom'
  294. * @param array $params POST parameters to send
  295. * @return mixed associative array for JSON, DOMDocument for XML/Atom
  296. * @throws Exception on low-level error conditions
  297. */
  298. protected function api($path, $style, $params=array())
  299. {
  300. $response = $this->hit("api/$path.$style", $params, true);
  301. $body = $response->getBody();
  302. if ($style == 'json') {
  303. return $this->parseJson($path, $body);
  304. } else if ($style == 'xml' || $style == 'atom') {
  305. return $this->parseXml($path, $body);
  306. } else {
  307. throw new Exception("API needs to be JSON, XML, or Atom");
  308. }
  309. }
  310. /**
  311. * Register the account.
  312. *
  313. * Unfortunately there's not an API method for registering, so we fake it.
  314. */
  315. function register()
  316. {
  317. $this->log("Registering user %s on %s",
  318. $this->username,
  319. $this->basepath);
  320. $ret = $this->web('main/register', 'form_register',
  321. array('nickname' => $this->username,
  322. 'password' => $this->password,
  323. 'confirm' => $this->password,
  324. 'fullname' => $this->fullname,
  325. 'homepage' => $this->homepage,
  326. 'bio' => $this->bio,
  327. 'license' => 1,
  328. 'submit' => 'Register'));
  329. }
  330. /**
  331. * @return string canonical URI/URL to profile page
  332. */
  333. function getProfileUri()
  334. {
  335. $data = $this->api('account/verify_credentials', 'json');
  336. $id = $data['id'];
  337. return $this->basepath . '/user/' . $id;
  338. }
  339. /**
  340. * @return string human-friendly URL to profile page
  341. */
  342. function getProfileLink()
  343. {
  344. return $this->basepath . '/' . $this->username;
  345. }
  346. /**
  347. * Check that the account has been registered and can be used.
  348. * On failure, throws a test failure exception.
  349. */
  350. function assertRegistered()
  351. {
  352. $this->log("Confirming %s is registered on %s",
  353. $this->username,
  354. $this->basepath);
  355. $data = $this->api('account/verify_credentials', 'json');
  356. $this->assertEqual($this->username, $data['screen_name']);
  357. $this->assertEqual($this->fullname, $data['name']);
  358. $this->assertEqual($this->homepage, $data['url']);
  359. $this->assertEqual($this->bio, $data['description']);
  360. $this->log(" looks good!");
  361. }
  362. /**
  363. * Post a given message from this account
  364. * @param string $message
  365. * @return string URL/URI of notice
  366. * @todo reply, location options
  367. */
  368. function post($message)
  369. {
  370. $this->log("Posting notice as %s on %s: %s",
  371. $this->username,
  372. $this->basepath,
  373. $message);
  374. $data = $this->api('statuses/update', 'json',
  375. array('status' => $message));
  376. $url = $this->basepath . '/notice/' . $data['id'];
  377. return $url;
  378. }
  379. /**
  380. * Check that this account has received the notice.
  381. * @param string $notice_uri URI for the notice to check for
  382. */
  383. function assertReceived($notice_uri)
  384. {
  385. $timeout = 5;
  386. $tries = 6;
  387. while ($tries) {
  388. $ok = $this->checkReceived($notice_uri);
  389. if ($ok) {
  390. return true;
  391. }
  392. $tries--;
  393. if ($tries) {
  394. $this->log(" didn't see it yet, waiting $timeout seconds");
  395. sleep($timeout);
  396. }
  397. }
  398. throw new Exception(" message $notice_uri not received by $this->username");
  399. }
  400. /**
  401. * Pull the user's home timeline to check if a notice with the given
  402. * source URL has been received recently.
  403. * If we don't see it, we'll try a couple more times up to 10 seconds.
  404. *
  405. * @param string $notice_uri
  406. */
  407. function checkReceived($notice_uri)
  408. {
  409. $this->log("Checking if %s on %s received notice %s",
  410. $this->username,
  411. $this->basepath,
  412. $notice_uri);
  413. $params = array();
  414. $dom = $this->api('statuses/home_timeline', 'atom', $params);
  415. $xml = simplexml_import_dom($dom);
  416. if (!$xml->entry) {
  417. return false;
  418. }
  419. if (is_array($xml->entry)) {
  420. $entries = $xml->entry;
  421. } else {
  422. $entries = array($xml->entry);
  423. }
  424. foreach ($entries as $entry) {
  425. if ($entry->id == $notice_uri) {
  426. $this->log(" found it $notice_uri");
  427. return true;
  428. }
  429. }
  430. return false;
  431. }
  432. /**
  433. * @param string $profile user page link or webfinger
  434. */
  435. function subscribe($profile)
  436. {
  437. // This uses the command interface, since there's not currently
  438. // a friendly Twit-API way to do a fresh remote subscription and
  439. // the web form's a pain to use.
  440. $this->post('follow ' . $profile);
  441. }
  442. /**
  443. * @param string $profile user page link or webfinger
  444. */
  445. function unsubscribe($profile)
  446. {
  447. // This uses the command interface, since there's not currently
  448. // a friendly Twit-API way to do a fresh remote subscription and
  449. // the web form's a pain to use.
  450. $this->post('leave ' . $profile);
  451. }
  452. /**
  453. * Check that this account is subscribed to the given profile.
  454. * @param string $profile_uri URI for the profile to check for
  455. * @return boolean
  456. */
  457. function hasSubscription($profile_uri)
  458. {
  459. $this->log("Checking if $this->username has a subscription to $profile_uri");
  460. $me = $this->getProfileUri();
  461. return $this->checkSubscription($me, $profile_uri);
  462. }
  463. /**
  464. * Check that this account is subscribed to by the given profile.
  465. * @param string $profile_uri URI for the profile to check for
  466. * @return boolean
  467. */
  468. function hasSubscriber($profile_uri)
  469. {
  470. $this->log("Checking if $this->username is subscribed to by $profile_uri");
  471. $me = $this->getProfileUri();
  472. return $this->checkSubscription($profile_uri, $me);
  473. }
  474. protected function checkSubscription($subscriber, $subscribed)
  475. {
  476. // Using FOAF as the API methods for checking the social graph
  477. // currently are unfriendly to remote profiles
  478. $ns_foaf = 'http://xmlns.com/foaf/0.1/';
  479. $ns_sioc = 'http://rdfs.org/sioc/ns#';
  480. $ns_rdf = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
  481. $dom = $this->xml($this->username . '/foaf');
  482. $agents = $dom->getElementsByTagNameNS($ns_foaf, 'Agent');
  483. foreach ($agents as $agent) {
  484. $agent_uri = $agent->getAttributeNS($ns_rdf, 'about');
  485. if ($agent_uri == $subscriber) {
  486. $follows = $agent->getElementsByTagNameNS($ns_sioc, 'follows');
  487. foreach ($follows as $follow) {
  488. $target = $follow->getAttributeNS($ns_rdf, 'resource');
  489. if ($target == ($subscribed . '#acct')) {
  490. $this->log(" confirmed $subscriber subscribed to $subscribed");
  491. return true;
  492. }
  493. }
  494. $this->log(" we found $subscriber but they don't follow $subscribed");
  495. return false;
  496. }
  497. }
  498. $this->log(" can't find $subscriber in {$this->username}'s social graph.");
  499. return false;
  500. }
  501. /**
  502. * Create a group on this site.
  503. *
  504. * @param string $nickname
  505. * @param array $options
  506. * @return string: profile URL for the group
  507. */
  508. function createGroup($nickname, $options=array()) {
  509. $this->log("Creating group as %s on %s: %s",
  510. $this->username,
  511. $this->basepath,
  512. $nickname);
  513. $data = $this->api('statusnet/groups/create', 'json',
  514. array_merge(array('nickname' => $nickname), $options));
  515. $url = $data['url'];
  516. if ($url) {
  517. $this->log(' created as %s', $url);
  518. } else {
  519. $this->log(' failed? %s', var_export($data, true));
  520. }
  521. return $url;
  522. }
  523. function groupInfo($nickname) {
  524. $data = $this->api('statusnet/groups/show', 'json', array(
  525. 'id' => $nickname
  526. ));
  527. }
  528. /**
  529. * Join a group.
  530. *
  531. * @param string $group nickname or URL
  532. */
  533. function joinGroup($group) {
  534. $this->post('join ' . $group);
  535. }
  536. /**
  537. * Leave a group.
  538. *
  539. * @param string $group nickname or URL
  540. */
  541. function leaveGroup($group) {
  542. $this->post('drop ' . $group);
  543. }
  544. /**
  545. *
  546. * @param string $nickname
  547. * @return
  548. */
  549. function inGroup($nickname) {
  550. // @todo
  551. }
  552. }
  553. // @fixme switch to commandline.inc?
  554. $timeout = HTTP_TIMEOUT;
  555. $args = array();
  556. $options = array();
  557. foreach (array_slice($_SERVER['argv'], 1) as $arg) {
  558. if (substr($arg, 0, 2) == '--') {
  559. $bits = explode('=', substr($arg, 2), 2);
  560. if (count($bits == 2)) {
  561. list($key, $val) = $bits;
  562. $options[$key] = $val;
  563. } else {
  564. list($key) = $bits;
  565. $options[$key] = true;
  566. }
  567. } else {
  568. $args[] = $arg;
  569. }
  570. }
  571. if (count($args) < 2) {
  572. print <<<END_HELP
  573. remote-tests.php [options] <url1> <url2>
  574. --timeout=## change HTTP timeout from default {$timeout}s
  575. url1: base URL of a StatusNet instance
  576. url2: base URL of another StatusNet instance
  577. This will register user accounts on the two given StatusNet instances
  578. and run some tests to confirm that OStatus subscription and posting
  579. between the two sites works correctly.
  580. END_HELP;
  581. exit(1);
  582. }
  583. $a = $args[0];
  584. $b = $args[1];
  585. if (isset($options['timeout'])) {
  586. $timeout = intval($options['timeout']);
  587. }
  588. $tester = new OStatusTester($a, $b, $timeout);
  589. $tester->run();