The clean & modern RSS server that doesn't give you any crap. https://thearsse.com/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

TestREST.php 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. <?php
  2. /** @license MIT
  3. * Copyright 2017 J. King, Dustin Wilson et al.
  4. * See LICENSE and AUTHORS files for details */
  5. declare(strict_types=1);
  6. namespace JKingWeb\Arsse\TestCase\REST;
  7. use JKingWeb\Arsse\Arsse;
  8. use JKingWeb\Arsse\User;
  9. use JKingWeb\Arsse\REST;
  10. use JKingWeb\Arsse\REST\Handler;
  11. use JKingWeb\Arsse\REST\Exception501;
  12. use JKingWeb\Arsse\REST\NextCloudNews\V1_2 as NCN;
  13. use JKingWeb\Arsse\REST\TinyTinyRSS\API as TTRSS;
  14. use Psr\Http\Message\RequestInterface;
  15. use Psr\Http\Message\ResponseInterface;
  16. use Zend\Diactoros\Request;
  17. use Zend\Diactoros\Response;
  18. use Zend\Diactoros\ServerRequest;
  19. use Zend\Diactoros\Response\TextResponse;
  20. use Zend\Diactoros\Response\EmptyResponse;
  21. use Phake;
  22. /** @covers \JKingWeb\Arsse\REST */
  23. class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
  24. /** @dataProvider provideApiMatchData */
  25. public function testMatchAUrlToAnApi($apiList, string $input, array $exp) {
  26. $r = new REST($apiList);
  27. try {
  28. $out = $r->apiMatch($input);
  29. } catch (Exception501 $e) {
  30. $out = [];
  31. }
  32. $this->assertEquals($exp, $out);
  33. }
  34. public function provideApiMatchData() {
  35. $real = null;
  36. $fake = [
  37. 'unstripped' => ['match' => "/full/url", 'strip' => "", 'class' => "UnstrippedProtocol"],
  38. ];
  39. return [
  40. [$real, "/index.php/apps/news/api/v1-2/feeds", ["ncn_v1-2", "/feeds", \JKingWeb\Arsse\REST\NextCloudNews\V1_2::class]],
  41. [$real, "/index.php/apps/news/api/v1-2", ["ncn", "/v1-2", \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]],
  42. [$real, "/index.php/apps/news/api/", ["ncn", "/", \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]],
  43. [$real, "/index%2Ephp/apps/news/api/", ["ncn", "/", \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]],
  44. [$real, "/index.php/apps/news/", []],
  45. [$real, "/index!php/apps/news/api/", []],
  46. [$real, "/tt-rss/api/index.php", ["ttrss_api", "/index.php", \JKingWeb\Arsse\REST\TinyTinyRSS\API::class]],
  47. [$real, "/tt-rss/api", ["ttrss_api", "", \JKingWeb\Arsse\REST\TinyTinyRSS\API::class]],
  48. [$real, "/tt-rss/API", []],
  49. [$real, "/tt-rss/api-bogus", []],
  50. [$real, "/tt-rss/api bogus", []],
  51. [$real, "/tt-rss/feed-icons/", ["ttrss_icon", "", \JKingWeb\Arsse\REST\TinyTinyRSS\Icon::class]],
  52. [$real, "/tt-rss/feed-icons/", ["ttrss_icon", "", \JKingWeb\Arsse\REST\TinyTinyRSS\Icon::class]],
  53. [$real, "/tt-rss/feed-icons", []],
  54. [$fake, "/full/url/", ["unstripped", "/full/url/", "UnstrippedProtocol"]],
  55. [$fake, "/full/url-not", []],
  56. ];
  57. }
  58. /** @dataProvider provideAuthenticableRequests */
  59. public function testAuthenticateRequests(array $serverParams, array $expAttr) {
  60. $r = new REST();
  61. // create a mock user manager
  62. Arsse::$user = Phake::mock(User::class);
  63. Phake::when(Arsse::$user)->auth->thenReturn(false);
  64. Phake::when(Arsse::$user)->auth("john.doe@example.com", "secret")->thenReturn(true);
  65. Phake::when(Arsse::$user)->auth("john.doe@example.com", "")->thenReturn(true);
  66. Phake::when(Arsse::$user)->auth("someone.else@example.com", "")->thenReturn(true);
  67. // create an input server request
  68. $req = new ServerRequest($serverParams);
  69. // create the expected output
  70. $exp = $req;
  71. foreach ($expAttr as $key => $value) {
  72. $exp = $exp->withAttribute($key, $value);
  73. }
  74. $act = $r->authenticateRequest($req);
  75. $this->assertMessage($exp, $act);
  76. }
  77. public function provideAuthenticableRequests() {
  78. return [
  79. [['PHP_AUTH_USER' => "john.doe@example.com", 'PHP_AUTH_PW' => "secret"], ['authenticated' => true, 'authenticatedUser' => "john.doe@example.com"]],
  80. [['PHP_AUTH_USER' => "john.doe@example.com", 'PHP_AUTH_PW' => "secret", 'REMOTE_USER' => "jane.doe@example.com"], ['authenticated' => true, 'authenticatedUser' => "john.doe@example.com"]],
  81. [['PHP_AUTH_USER' => "jane.doe@example.com", 'PHP_AUTH_PW' => "secret"], ['authenticationFailed' => true]],
  82. [['PHP_AUTH_USER' => "john.doe@example.com", 'PHP_AUTH_PW' => "superman"], ['authenticationFailed' => true]],
  83. [['REMOTE_USER' => "john.doe@example.com"], ['authenticated' => true, 'authenticatedUser' => "john.doe@example.com"]],
  84. [['REMOTE_USER' => "someone.else@example.com"], ['authenticated' => true, 'authenticatedUser' => "someone.else@example.com"]],
  85. [['REMOTE_USER' => "jane.doe@example.com"], ['authenticationFailed' => true]],
  86. [[], []],
  87. ];
  88. }
  89. public function testSendAuthenticationChallenges() {
  90. self::setConf();
  91. $r = new REST();
  92. $in = new EmptyResponse(401);
  93. $exp = $in->withHeader("WWW-Authenticate", 'Basic realm="OOK"');
  94. $act = $r->challenge($in, "OOK");
  95. $this->assertMessage($exp, $act);
  96. $exp = $in->withHeader("WWW-Authenticate", 'Basic realm="'.Arsse::$conf->httpRealm.'"');
  97. $act = $r->challenge($in);
  98. $this->assertMessage($exp, $act);
  99. }
  100. /** @dataProvider provideUnnormalizedOrigins */
  101. public function testNormalizeOrigins(string $origin, string $exp, array $ports = null) {
  102. $r = new REST();
  103. $act = $r->corsNormalizeOrigin($origin, $ports);
  104. $this->assertSame($exp, $act);
  105. }
  106. public function provideUnnormalizedOrigins() {
  107. return [
  108. ["null", "null"],
  109. ["http://example.com", "http://example.com"],
  110. ["http://example.com:80", "http://example.com"],
  111. ["http://example.com:8%30", "http://example.com"],
  112. ["http://example.com:8080", "http://example.com:8080"],
  113. ["http://[2001:0db8:0:0:0:0:2:1]", "http://[2001:db8::2:1]"],
  114. ["http://example", "http://example"],
  115. ["http://ex%41mple", "http://example"],
  116. ["http://ex%41mple.co.uk", "http://example.co.uk"],
  117. ["http://ex%41mple.co%2euk", "http://example.co%2Euk"],
  118. ["http://example/", ""],
  119. ["http://example?", ""],
  120. ["http://example#", ""],
  121. ["http://user@example", ""],
  122. ["http://user:pass@example", ""],
  123. ["http://[example", ""],
  124. ["http://[2bef]", ""],
  125. ["http://example%2F", "http://example%2F"],
  126. ["HTTP://example", "http://example"],
  127. ["HTTP://EXAMPLE", "http://example"],
  128. ["%48%54%54%50://example", "http://example"],
  129. ["http:%2F%2Fexample", ""],
  130. ["https://example", "https://example"],
  131. ["https://example:443", "https://example"],
  132. ["https://example:80", "https://example:80"],
  133. ["ssh://example", "ssh://example"],
  134. ["ssh://example:22", "ssh://example:22"],
  135. ["ssh://example:22", "ssh://example", ['ssh' => 22]],
  136. ["SSH://example:22", "ssh://example", ['ssh' => 22]],
  137. ["ssh://example:22", "ssh://example", ['ssh' => "22"]],
  138. ["ssh://example:22", "ssh://example:22", ['SSH' => "22"]],
  139. ];
  140. }
  141. /** @dataProvider provideCorsNegotiations */
  142. public function testNegotiateCors($origin, bool $exp, string $allowed = null, string $denied = null) {
  143. self::setConf();
  144. $r = Phake::partialMock(REST::class);
  145. Phake::when($r)->corsNormalizeOrigin->thenReturnCallback(function($origin) {
  146. return $origin;
  147. });
  148. $headers = isset($origin) ? ['Origin' => $origin] : [];
  149. $req = new Request("", "GET", "php://memory", $headers);
  150. $act = $r->corsNegotiate($req, $allowed, $denied);
  151. $this->assertSame($exp, $act);
  152. }
  153. public function provideCorsNegotiations() {
  154. return [
  155. ["http://example", true ],
  156. ["http://example", true, "http://example", "*" ],
  157. ["http://example", false, "http://example", "http://example"],
  158. ["http://example", false, "https://example", "*" ],
  159. ["http://example", false, "*", "*" ],
  160. ["http://example", true, "*", "" ],
  161. ["http://example", false, "", "" ],
  162. ["null", false ],
  163. ["null", true, "null", "*" ],
  164. ["null", false, "null", "null" ],
  165. ["null", false, "*", "*" ],
  166. ["null", false, "*", "" ],
  167. ["null", false, "", "" ],
  168. ["", false ],
  169. ["", false, "", "*" ],
  170. ["", false, "", "" ],
  171. ["", false, "*", "*" ],
  172. ["", false, "*", "" ],
  173. [["null", "http://example"], false, "*", "" ],
  174. [null, false, "*", "" ],
  175. ];
  176. }
  177. /** @dataProvider provideCorsHeaders */
  178. public function testAddCorsHeaders(string $reqMethod, array $reqHeaders, array $resHeaders, array $expHeaders) {
  179. $r = new REST();
  180. $req = new Request("", $reqMethod, "php://memory", $reqHeaders);
  181. $res = new EmptyResponse(204, $resHeaders);
  182. $exp = new EmptyResponse(204, $expHeaders);
  183. $act = $r->corsApply($res, $req);
  184. $this->assertMessage($exp, $act);
  185. }
  186. public function provideCorsHeaders() {
  187. return [
  188. ["GET", ['Origin' => "null"], [], [
  189. 'Access-Control-Allow-Origin' => "null",
  190. 'Access-Control-Allow-Credentials' => "true",
  191. 'Vary' => "Origin",
  192. ]],
  193. ["GET", ['Origin' => "http://example"], [], [
  194. 'Access-Control-Allow-Origin' => "http://example",
  195. 'Access-Control-Allow-Credentials' => "true",
  196. 'Vary' => "Origin",
  197. ]],
  198. ["GET", ['Origin' => "http://example"], ['Content-Type' => "text/plain; charset=utf-8"], [
  199. 'Access-Control-Allow-Origin' => "http://example",
  200. 'Access-Control-Allow-Credentials' => "true",
  201. 'Vary' => "Origin",
  202. 'Content-Type' => "text/plain; charset=utf-8",
  203. ]],
  204. ["GET", ['Origin' => "http://example"], ['Vary' => "Content-Type"], [
  205. 'Access-Control-Allow-Origin' => "http://example",
  206. 'Access-Control-Allow-Credentials' => "true",
  207. 'Vary' => ["Content-Type", "Origin"],
  208. ]],
  209. ["OPTIONS", ['Origin' => "http://example"], [], [
  210. 'Access-Control-Allow-Origin' => "http://example",
  211. 'Access-Control-Allow-Credentials' => "true",
  212. 'Access-Control-Max-Age' => (string) (60 *60 *24),
  213. 'Vary' => "Origin",
  214. ]],
  215. ["OPTIONS", ['Origin' => "http://example"], ['Allow' => "GET, PUT, HEAD, OPTIONS"], [
  216. 'Allow' => "GET, PUT, HEAD, OPTIONS",
  217. 'Access-Control-Allow-Origin' => "http://example",
  218. 'Access-Control-Allow-Credentials' => "true",
  219. 'Access-Control-Allow-Methods' => "GET, PUT, HEAD, OPTIONS",
  220. 'Access-Control-Max-Age' => (string) (60 *60 *24),
  221. 'Vary' => "Origin",
  222. ]],
  223. ["OPTIONS", ['Origin' => "http://example", 'Access-Control-Request-Headers' => "Content-Type, If-None-Match"], [], [
  224. 'Access-Control-Allow-Origin' => "http://example",
  225. 'Access-Control-Allow-Credentials' => "true",
  226. 'Access-Control-Allow-Headers' => "Content-Type, If-None-Match",
  227. 'Access-Control-Max-Age' => (string) (60 *60 *24),
  228. 'Vary' => "Origin",
  229. ]],
  230. ["OPTIONS", ['Origin' => "http://example", 'Access-Control-Request-Headers' => ["Content-Type", "If-None-Match"]], [], [
  231. 'Access-Control-Allow-Origin' => "http://example",
  232. 'Access-Control-Allow-Credentials' => "true",
  233. 'Access-Control-Allow-Headers' => "Content-Type,If-None-Match",
  234. 'Access-Control-Max-Age' => (string) (60 *60 *24),
  235. 'Vary' => "Origin",
  236. ]],
  237. ];
  238. }
  239. /** @dataProvider provideUnnormalizedResponses */
  240. public function testNormalizeHttpResponses(ResponseInterface $res, ResponseInterface $exp, RequestInterface $req = null) {
  241. $r = Phake::partialMock(REST::class);
  242. Phake::when($r)->corsNegotiate->thenReturn(true);
  243. Phake::when($r)->challenge->thenReturnCallback(function($res) {
  244. return $res->withHeader("WWW-Authenticate", "Fake Value");
  245. });
  246. Phake::when($r)->corsApply->thenReturnCallback(function($res) {
  247. return $res;
  248. });
  249. $act = $r->normalizeResponse($res, $req);
  250. $this->assertMessage($exp, $act);
  251. }
  252. public function provideUnnormalizedResponses() {
  253. $stream = fopen("php://memory", "w+b");
  254. fwrite($stream, "ook");
  255. return [
  256. [new EmptyResponse(204), new EmptyResponse(204)],
  257. [new EmptyResponse(401), new EmptyResponse(401, ['WWW-Authenticate' => "Fake Value"])],
  258. [new EmptyResponse(204, ['Allow' => "PUT"]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])],
  259. [new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])],
  260. [new EmptyResponse(204, ['Allow' => "PUT,OPTIONS"]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])],
  261. [new EmptyResponse(204, ['Allow' => ["PUT", "OPTIONS"]]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])],
  262. [new EmptyResponse(204, ['Allow' => ["PUT, DELETE", "OPTIONS"]]), new EmptyResponse(204, ['Allow' => "PUT, DELETE, OPTIONS"])],
  263. [new EmptyResponse(204, ['Allow' => "HEAD,GET"]), new EmptyResponse(204, ['Allow' => "HEAD, GET, OPTIONS"])],
  264. [new EmptyResponse(204, ['Allow' => "GET"]), new EmptyResponse(204, ['Allow' => "GET, HEAD, OPTIONS"])],
  265. [new TextResponse("ook", 200), new TextResponse("ook", 200, ['Content-Length' => "3"])],
  266. [new TextResponse("", 200), new TextResponse("", 200, ['Content-Length' => "0"])],
  267. [new TextResponse("ook", 404), new TextResponse("ook", 404, ['Content-Length' => "3"])],
  268. [new TextResponse("", 404), new TextResponse("", 404)],
  269. [new Response($stream, 200), new Response($stream, 200, ['Content-Length' => "3"]), new Request("", "GET")],
  270. [new Response($stream, 200), new EmptyResponse(200, ['Content-Length' => "3"]), new Request("", "HEAD")],
  271. ];
  272. }
  273. public function testCreateHandlers() {
  274. $r = new REST();
  275. foreach (REST::API_LIST as $api) {
  276. $class = $api['class'];
  277. $this->assertInstanceOf(Handler::class, $r->getHandler($class));
  278. }
  279. }
  280. /** @dataProvider provideMockRequests */
  281. public function testDispatchRequests(ServerRequest $req, string $method, bool $called, string $class = "", string $target ="") {
  282. $r = Phake::partialMock(REST::class);
  283. Phake::when($r)->normalizeResponse->thenReturnCallback(function($res) {
  284. return $res;
  285. });
  286. Phake::when($r)->authenticateRequest->thenReturnCallback(function($req) {
  287. return $req;
  288. });
  289. if ($called) {
  290. $h = Phake::mock($class);
  291. Phake::when($r)->getHandler($class)->thenReturn($h);
  292. Phake::when($h)->dispatch->thenReturn(new EmptyResponse(204));
  293. }
  294. $out = $r->dispatch($req);
  295. $this->assertInstanceOf(ResponseInterface::class, $out);
  296. if ($called) {
  297. Phake::verify($r)->authenticateRequest;
  298. Phake::verify($h)->dispatch(Phake::capture($in));
  299. $this->assertSame($method, $in->getMethod());
  300. $this->assertSame($target, $in->getRequestTarget());
  301. } else {
  302. $this->assertSame(501, $out->getStatusCode());
  303. }
  304. Phake::verify($r)->apiMatch;
  305. Phake::verify($r)->normalizeResponse;
  306. }
  307. public function provideMockRequests() {
  308. return [
  309. [new ServerRequest([], [], "/index.php/apps/news/api/v1-2/feeds", "GET"), "GET", true, NCN::class, "/feeds"],
  310. [new ServerRequest([], [], "/index.php/apps/news/api/v1-2/feeds", "HEAD"), "GET", true, NCN::class, "/feeds"],
  311. [new ServerRequest([], [], "/index.php/apps/news/api/v1-2/feeds", "get"), "GET", true, NCN::class, "/feeds"],
  312. [new ServerRequest([], [], "/index.php/apps/news/api/v1-2/feeds", "head"), "GET", true, NCN::class, "/feeds"],
  313. [new ServerRequest([], [], "/tt-rss/api/", "POST"), "POST", true, TTRSS::class, "/"],
  314. [new ServerRequest([], [], "/no/such/api/", "HEAD"), "GET", false],
  315. [new ServerRequest([], [], "/no/such/api/", "GET"), "GET", false],
  316. ];
  317. }
  318. }