test_rest_tornado.py 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102
  1. import copy
  2. import hashlib
  3. import os
  4. import shutil
  5. from urllib.parse import urlencode, urlparse
  6. import salt.auth
  7. import salt.utils.event
  8. import salt.utils.json
  9. import salt.utils.yaml
  10. from tests.support.events import eventpublisher_process
  11. from tests.support.helpers import patched_environ, slowTest
  12. from tests.support.mixins import AdaptedConfigurationTestCaseMixin
  13. from tests.support.mock import MagicMock, patch
  14. from tests.support.runtests import RUNTIME_VARS
  15. from tests.support.unit import TestCase, skipIf
  16. try:
  17. HAS_TORNADO = True
  18. except ImportError:
  19. HAS_TORNADO = False
  20. # pylint: disable=import-error
  21. try:
  22. import salt.ext.tornado.escape
  23. import salt.ext.tornado.testing
  24. import salt.ext.tornado.concurrent
  25. from salt.ext.tornado.testing import AsyncTestCase, AsyncHTTPTestCase, gen_test
  26. from salt.ext.tornado.httpclient import HTTPRequest, HTTPError
  27. from salt.ext.tornado.websocket import websocket_connect
  28. import salt.netapi.rest_tornado as rest_tornado
  29. from salt.netapi.rest_tornado import saltnado
  30. HAS_TORNADO = True
  31. except ImportError:
  32. HAS_TORNADO = False
  33. # Create fake test case classes so we can properly skip the test case
  34. class AsyncTestCase:
  35. pass
  36. class AsyncHTTPTestCase:
  37. pass
  38. # pylint: enable=import-error
  39. @skipIf(
  40. not HAS_TORNADO, "The tornado package needs to be installed"
  41. ) # pylint: disable=W0223
  42. class SaltnadoTestCase(TestCase, AdaptedConfigurationTestCaseMixin, AsyncHTTPTestCase):
  43. """
  44. Mixin to hold some shared things
  45. """
  46. content_type_map = {
  47. "json": "application/json",
  48. "json-utf8": "application/json; charset=utf-8",
  49. "yaml": "application/x-yaml",
  50. "text": "text/plain",
  51. "form": "application/x-www-form-urlencoded",
  52. "xml": "application/xml",
  53. "real-accept-header-json": "application/json, text/javascript, */*; q=0.01",
  54. "real-accept-header-yaml": "application/x-yaml, text/yaml, */*; q=0.01",
  55. }
  56. auth_creds = (
  57. ("username", "saltdev_api"),
  58. ("password", "saltdev"),
  59. ("eauth", "auto"),
  60. )
  61. @property
  62. def auth_creds_dict(self):
  63. return dict(self.auth_creds)
  64. @property
  65. def opts(self):
  66. return self.get_temp_config("client_config")
  67. @property
  68. def mod_opts(self):
  69. return self.get_temp_config("minion")
  70. @property
  71. def auth(self):
  72. if not hasattr(self, "__auth"):
  73. self.__auth = salt.auth.LoadAuth(self.opts)
  74. return self.__auth
  75. @property
  76. def token(self):
  77. """ Mint and return a valid token for auth_creds """
  78. return self.auth.mk_token(self.auth_creds_dict)
  79. def setUp(self):
  80. super().setUp()
  81. self.patched_environ = patched_environ(ASYNC_TEST_TIMEOUT="30")
  82. self.patched_environ.__enter__()
  83. self.addCleanup(self.patched_environ.__exit__)
  84. def tearDown(self):
  85. super().tearDown()
  86. if hasattr(self, "http_server"):
  87. del self.http_server
  88. if hasattr(self, "io_loop"):
  89. del self.io_loop
  90. if hasattr(self, "_app"):
  91. del self._app
  92. if hasattr(self, "http_client"):
  93. del self.http_client
  94. if hasattr(self, "__port"):
  95. del self.__port
  96. if hasattr(self, "_AsyncHTTPTestCase__port"):
  97. del self._AsyncHTTPTestCase__port
  98. if hasattr(self, "__auth"):
  99. del self.__auth
  100. if hasattr(self, "_SaltnadoTestCase__auth"):
  101. del self._SaltnadoTestCase__auth
  102. if hasattr(self, "_test_generator"):
  103. del self._test_generator
  104. if hasattr(self, "application"):
  105. del self.application
  106. if hasattr(self, "patched_environ"):
  107. del self.patched_environ
  108. def build_tornado_app(self, urls):
  109. application = salt.ext.tornado.web.Application(urls, debug=True)
  110. application.auth = self.auth
  111. application.opts = self.opts
  112. application.mod_opts = self.mod_opts
  113. return application
  114. def decode_body(self, response):
  115. if response is None:
  116. return response
  117. if response.body:
  118. # Decode it
  119. if response.headers.get("Content-Type") == "application/json":
  120. response._body = response.body.decode("utf-8")
  121. else:
  122. response._body = salt.ext.tornado.escape.native_str(response.body)
  123. return response
  124. def fetch(self, path, **kwargs):
  125. return self.decode_body(super().fetch(path, **kwargs))
  126. class TestBaseSaltAPIHandler(SaltnadoTestCase):
  127. def get_app(self):
  128. class StubHandler(saltnado.BaseSaltAPIHandler): # pylint: disable=W0223
  129. def get(self, *args, **kwargs):
  130. return self.echo_stuff()
  131. def post(self): # pylint: disable=arguments-differ
  132. return self.echo_stuff()
  133. def echo_stuff(self):
  134. ret_dict = {"foo": "bar"}
  135. attrs = (
  136. "token",
  137. "start",
  138. "connected",
  139. "lowstate",
  140. )
  141. for attr in attrs:
  142. ret_dict[attr] = getattr(self, attr)
  143. self.write(self.serialize(ret_dict))
  144. urls = [("/", StubHandler), ("/(.*)", StubHandler)]
  145. return self.build_tornado_app(urls)
  146. def test_accept_content_type(self):
  147. """
  148. Test the base handler's accept picking
  149. """
  150. # send NO accept header, should come back with json
  151. response = self.fetch("/")
  152. self.assertEqual(
  153. response.headers["Content-Type"], self.content_type_map["json"]
  154. )
  155. self.assertEqual(type(salt.utils.json.loads(response.body)), dict)
  156. # Request application/json
  157. response = self.fetch("/", headers={"Accept": self.content_type_map["json"]})
  158. self.assertEqual(
  159. response.headers["Content-Type"], self.content_type_map["json"]
  160. )
  161. self.assertEqual(type(salt.utils.json.loads(response.body)), dict)
  162. # Request application/x-yaml
  163. response = self.fetch("/", headers={"Accept": self.content_type_map["yaml"]})
  164. self.assertEqual(
  165. response.headers["Content-Type"], self.content_type_map["yaml"]
  166. )
  167. self.assertEqual(type(salt.utils.yaml.safe_load(response.body)), dict)
  168. # Request not supported content-type
  169. response = self.fetch("/", headers={"Accept": self.content_type_map["xml"]})
  170. self.assertEqual(response.code, 406)
  171. # Request some JSON with a browser like Accept
  172. accept_header = self.content_type_map["real-accept-header-json"]
  173. response = self.fetch("/", headers={"Accept": accept_header})
  174. self.assertEqual(
  175. response.headers["Content-Type"], self.content_type_map["json"]
  176. )
  177. self.assertEqual(type(salt.utils.json.loads(response.body)), dict)
  178. # Request some YAML with a browser like Accept
  179. accept_header = self.content_type_map["real-accept-header-yaml"]
  180. response = self.fetch("/", headers={"Accept": accept_header})
  181. self.assertEqual(
  182. response.headers["Content-Type"], self.content_type_map["yaml"]
  183. )
  184. self.assertEqual(type(salt.utils.yaml.safe_load(response.body)), dict)
  185. def test_token(self):
  186. """
  187. Test that the token is returned correctly
  188. """
  189. token = salt.utils.json.loads(self.fetch("/").body)["token"]
  190. self.assertIs(token, None)
  191. # send a token as a header
  192. response = self.fetch("/", headers={saltnado.AUTH_TOKEN_HEADER: "foo"})
  193. token = salt.utils.json.loads(response.body)["token"]
  194. self.assertEqual(token, "foo")
  195. # send a token as a cookie
  196. response = self.fetch(
  197. "/", headers={"Cookie": "{}=foo".format(saltnado.AUTH_COOKIE_NAME)}
  198. )
  199. token = salt.utils.json.loads(response.body)["token"]
  200. self.assertEqual(token, "foo")
  201. # send both, make sure its the header
  202. response = self.fetch(
  203. "/",
  204. headers={
  205. saltnado.AUTH_TOKEN_HEADER: "foo",
  206. "Cookie": "{}=bar".format(saltnado.AUTH_COOKIE_NAME),
  207. },
  208. )
  209. token = salt.utils.json.loads(response.body)["token"]
  210. self.assertEqual(token, "foo")
  211. def test_deserialize(self):
  212. """
  213. Send various encoded forms of lowstates (and bad ones) to make sure we
  214. handle deserialization correctly
  215. """
  216. valid_lowstate = [
  217. {"client": "local", "tgt": "*", "fun": "test.fib", "arg": ["10"]},
  218. {
  219. "client": "runner",
  220. "fun": "jobs.lookup_jid",
  221. "jid": "20130603122505459265",
  222. },
  223. ]
  224. # send as JSON
  225. response = self.fetch(
  226. "/",
  227. method="POST",
  228. body=salt.utils.json.dumps(valid_lowstate),
  229. headers={"Content-Type": self.content_type_map["json"]},
  230. )
  231. self.assertEqual(
  232. valid_lowstate, salt.utils.json.loads(response.body)["lowstate"]
  233. )
  234. # send yaml as json (should break)
  235. response = self.fetch(
  236. "/",
  237. method="POST",
  238. body=salt.utils.yaml.safe_dump(valid_lowstate),
  239. headers={"Content-Type": self.content_type_map["json"]},
  240. )
  241. self.assertEqual(response.code, 400)
  242. # send as yaml
  243. response = self.fetch(
  244. "/",
  245. method="POST",
  246. body=salt.utils.yaml.safe_dump(valid_lowstate),
  247. headers={"Content-Type": self.content_type_map["yaml"]},
  248. )
  249. self.assertEqual(
  250. valid_lowstate, salt.utils.json.loads(response.body)["lowstate"]
  251. )
  252. # send json as yaml (works since yaml is a superset of json)
  253. response = self.fetch(
  254. "/",
  255. method="POST",
  256. body=salt.utils.json.dumps(valid_lowstate),
  257. headers={"Content-Type": self.content_type_map["yaml"]},
  258. )
  259. self.assertEqual(
  260. valid_lowstate, salt.utils.json.loads(response.body)["lowstate"]
  261. )
  262. # send json as text/plain
  263. response = self.fetch(
  264. "/",
  265. method="POST",
  266. body=salt.utils.json.dumps(valid_lowstate),
  267. headers={"Content-Type": self.content_type_map["text"]},
  268. )
  269. self.assertEqual(
  270. valid_lowstate, salt.utils.json.loads(response.body)["lowstate"]
  271. )
  272. # send form-urlencoded
  273. form_lowstate = (
  274. ("client", "local"),
  275. ("tgt", "*"),
  276. ("fun", "test.fib"),
  277. ("arg", "10"),
  278. ("arg", "foo"),
  279. )
  280. response = self.fetch(
  281. "/",
  282. method="POST",
  283. body=urlencode(form_lowstate),
  284. headers={"Content-Type": self.content_type_map["form"]},
  285. )
  286. returned_lowstate = salt.utils.json.loads(response.body)["lowstate"]
  287. self.assertEqual(len(returned_lowstate), 1)
  288. returned_lowstate = returned_lowstate[0]
  289. self.assertEqual(returned_lowstate["client"], "local")
  290. self.assertEqual(returned_lowstate["tgt"], "*")
  291. self.assertEqual(returned_lowstate["fun"], "test.fib")
  292. self.assertEqual(returned_lowstate["arg"], ["10", "foo"])
  293. # Send json with utf8 charset
  294. response = self.fetch(
  295. "/",
  296. method="POST",
  297. body=salt.utils.json.dumps(valid_lowstate),
  298. headers={"Content-Type": self.content_type_map["json-utf8"]},
  299. )
  300. self.assertEqual(
  301. valid_lowstate, salt.utils.json.loads(response.body)["lowstate"]
  302. )
  303. def test_get_lowstate(self):
  304. """
  305. Test transformations low data of the function _get_lowstate
  306. """
  307. valid_lowstate = [
  308. {"client": "local", "tgt": "*", "fun": "test.fib", "arg": ["10"]}
  309. ]
  310. # Case 1. dictionary type of lowstate
  311. request_lowstate = {
  312. "client": "local",
  313. "tgt": "*",
  314. "fun": "test.fib",
  315. "arg": ["10"],
  316. }
  317. response = self.fetch(
  318. "/",
  319. method="POST",
  320. body=salt.utils.json.dumps(request_lowstate),
  321. headers={"Content-Type": self.content_type_map["json"]},
  322. )
  323. self.assertEqual(
  324. valid_lowstate, salt.utils.json.loads(response.body)["lowstate"]
  325. )
  326. # Case 2. string type of arg
  327. request_lowstate = {
  328. "client": "local",
  329. "tgt": "*",
  330. "fun": "test.fib",
  331. "arg": "10",
  332. }
  333. response = self.fetch(
  334. "/",
  335. method="POST",
  336. body=salt.utils.json.dumps(request_lowstate),
  337. headers={"Content-Type": self.content_type_map["json"]},
  338. )
  339. self.assertEqual(
  340. valid_lowstate, salt.utils.json.loads(response.body)["lowstate"]
  341. )
  342. # Case 3. Combine Case 1 and Case 2.
  343. request_lowstate = {
  344. "client": "local",
  345. "tgt": "*",
  346. "fun": "test.fib",
  347. "arg": "10",
  348. }
  349. # send as json
  350. response = self.fetch(
  351. "/",
  352. method="POST",
  353. body=salt.utils.json.dumps(request_lowstate),
  354. headers={"Content-Type": self.content_type_map["json"]},
  355. )
  356. self.assertEqual(
  357. valid_lowstate, salt.utils.json.loads(response.body)["lowstate"]
  358. )
  359. # send as yaml
  360. response = self.fetch(
  361. "/",
  362. method="POST",
  363. body=salt.utils.yaml.safe_dump(request_lowstate),
  364. headers={"Content-Type": self.content_type_map["yaml"]},
  365. )
  366. self.assertEqual(
  367. valid_lowstate, salt.utils.json.loads(response.body)["lowstate"]
  368. )
  369. # send as plain text
  370. response = self.fetch(
  371. "/",
  372. method="POST",
  373. body=salt.utils.json.dumps(request_lowstate),
  374. headers={"Content-Type": self.content_type_map["text"]},
  375. )
  376. self.assertEqual(
  377. valid_lowstate, salt.utils.json.loads(response.body)["lowstate"]
  378. )
  379. # send as form-urlencoded
  380. request_form_lowstate = (
  381. ("client", "local"),
  382. ("tgt", "*"),
  383. ("fun", "test.fib"),
  384. ("arg", "10"),
  385. )
  386. response = self.fetch(
  387. "/",
  388. method="POST",
  389. body=urlencode(request_form_lowstate),
  390. headers={"Content-Type": self.content_type_map["form"]},
  391. )
  392. self.assertEqual(
  393. valid_lowstate, salt.utils.json.loads(response.body)["lowstate"]
  394. )
  395. def test_cors_origin_wildcard(self):
  396. """
  397. Check that endpoints returns Access-Control-Allow-Origin
  398. """
  399. self._app.mod_opts["cors_origin"] = "*"
  400. headers = self.fetch("/").headers
  401. self.assertEqual(headers["Access-Control-Allow-Origin"], "*")
  402. def test_cors_origin_single(self):
  403. """
  404. Check that endpoints returns the Access-Control-Allow-Origin when
  405. only one origins is set
  406. """
  407. self._app.mod_opts["cors_origin"] = "http://example.foo"
  408. # Example.foo is an authorized origin
  409. headers = self.fetch("/", headers={"Origin": "http://example.foo"}).headers
  410. self.assertEqual(headers["Access-Control-Allow-Origin"], "http://example.foo")
  411. # Example2.foo is not an authorized origin
  412. headers = self.fetch("/", headers={"Origin": "http://example2.foo"}).headers
  413. self.assertEqual(headers.get("Access-Control-Allow-Origin"), None)
  414. def test_cors_origin_multiple(self):
  415. """
  416. Check that endpoints returns the Access-Control-Allow-Origin when
  417. multiple origins are set
  418. """
  419. self._app.mod_opts["cors_origin"] = ["http://example.foo", "http://foo.example"]
  420. # Example.foo is an authorized origin
  421. headers = self.fetch("/", headers={"Origin": "http://example.foo"}).headers
  422. self.assertEqual(headers["Access-Control-Allow-Origin"], "http://example.foo")
  423. # Example2.foo is not an authorized origin
  424. headers = self.fetch("/", headers={"Origin": "http://example2.foo"}).headers
  425. self.assertEqual(headers.get("Access-Control-Allow-Origin"), None)
  426. def test_cors_preflight_request(self):
  427. """
  428. Check that preflight request contains right headers
  429. """
  430. self._app.mod_opts["cors_origin"] = "*"
  431. request_headers = "X-Auth-Token, accept, content-type"
  432. preflight_headers = {
  433. "Access-Control-Request-Headers": request_headers,
  434. "Access-Control-Request-Method": "GET",
  435. }
  436. response = self.fetch("/", method="OPTIONS", headers=preflight_headers)
  437. headers = response.headers
  438. self.assertEqual(response.code, 204)
  439. self.assertEqual(headers["Access-Control-Allow-Headers"], request_headers)
  440. self.assertEqual(headers["Access-Control-Expose-Headers"], "X-Auth-Token")
  441. self.assertEqual(headers["Access-Control-Allow-Methods"], "OPTIONS, GET, POST")
  442. self.assertEqual(response.code, 204)
  443. def test_cors_origin_url_with_arguments(self):
  444. """
  445. Check that preflight requests works with url with components
  446. like jobs or minions endpoints.
  447. """
  448. self._app.mod_opts["cors_origin"] = "*"
  449. request_headers = "X-Auth-Token, accept, content-type"
  450. preflight_headers = {
  451. "Access-Control-Request-Headers": request_headers,
  452. "Access-Control-Request-Method": "GET",
  453. }
  454. response = self.fetch(
  455. "/1234567890", method="OPTIONS", headers=preflight_headers
  456. )
  457. headers = response.headers
  458. self.assertEqual(response.code, 204)
  459. self.assertEqual(headers["Access-Control-Allow-Origin"], "*")
  460. class TestWebhookSaltHandler(SaltnadoTestCase):
  461. def get_app(self):
  462. urls = [
  463. (r"/hook(/.*)?", saltnado.WebhookSaltAPIHandler),
  464. ]
  465. return self.build_tornado_app(urls)
  466. def test_hook_can_handle_get_parameters(self):
  467. with patch("salt.utils.event.get_event") as get_event:
  468. with patch.dict(self._app.mod_opts, {"webhook_disable_auth": True}):
  469. event = MagicMock()
  470. event.fire_event.return_value = True
  471. get_event.return_value = event
  472. response = self.fetch(
  473. "/hook/my_service/?param=1&param=2",
  474. body=salt.utils.json.dumps({}),
  475. method="POST",
  476. headers={"Content-Type": self.content_type_map["json"]},
  477. )
  478. self.assertEqual(response.code, 200, response.body)
  479. host = urlparse(response.effective_url).netloc
  480. event.fire_event.assert_called_once_with(
  481. {
  482. "headers": {
  483. "Content-Length": "2",
  484. "Connection": "close",
  485. "Content-Type": "application/json",
  486. "Host": host,
  487. "Accept-Encoding": "gzip",
  488. },
  489. "post": {},
  490. "get": {"param": ["1", "2"]},
  491. },
  492. "salt/netapi/hook/my_service/",
  493. )
  494. class TestSaltAuthHandler(SaltnadoTestCase):
  495. def get_app(self):
  496. urls = [("/login", saltnado.SaltAuthHandler)]
  497. return self.build_tornado_app(urls)
  498. def test_get(self):
  499. """
  500. We don't allow gets, so assert we get 401s
  501. """
  502. response = self.fetch("/login")
  503. self.assertEqual(response.code, 401)
  504. def test_login(self):
  505. """
  506. Test valid logins
  507. """
  508. # Test in form encoded
  509. response = self.fetch(
  510. "/login",
  511. method="POST",
  512. body=urlencode(self.auth_creds),
  513. headers={"Content-Type": self.content_type_map["form"]},
  514. )
  515. cookies = response.headers["Set-Cookie"]
  516. self.assertEqual(response.code, 200)
  517. response_obj = salt.utils.json.loads(response.body)["return"][0]
  518. token = response_obj["token"]
  519. self.assertIn("session_id={}".format(token), cookies)
  520. self.assertEqual(
  521. sorted(response_obj["perms"]),
  522. sorted(
  523. self.opts["external_auth"]["auto"][self.auth_creds_dict["username"]]
  524. ),
  525. )
  526. self.assertIn("token", response_obj) # TODO: verify that its valid?
  527. self.assertEqual(response_obj["user"], self.auth_creds_dict["username"])
  528. self.assertEqual(response_obj["eauth"], self.auth_creds_dict["eauth"])
  529. # Test in JSON
  530. response = self.fetch(
  531. "/login",
  532. method="POST",
  533. body=salt.utils.json.dumps(self.auth_creds_dict),
  534. headers={"Content-Type": self.content_type_map["json"]},
  535. )
  536. cookies = response.headers["Set-Cookie"]
  537. self.assertEqual(response.code, 200)
  538. response_obj = salt.utils.json.loads(response.body)["return"][0]
  539. token = response_obj["token"]
  540. self.assertIn("session_id={}".format(token), cookies)
  541. self.assertEqual(
  542. sorted(response_obj["perms"]),
  543. sorted(
  544. self.opts["external_auth"]["auto"][self.auth_creds_dict["username"]]
  545. ),
  546. )
  547. self.assertIn("token", response_obj) # TODO: verify that its valid?
  548. self.assertEqual(response_obj["user"], self.auth_creds_dict["username"])
  549. self.assertEqual(response_obj["eauth"], self.auth_creds_dict["eauth"])
  550. # Test in YAML
  551. response = self.fetch(
  552. "/login",
  553. method="POST",
  554. body=salt.utils.yaml.safe_dump(self.auth_creds_dict),
  555. headers={"Content-Type": self.content_type_map["yaml"]},
  556. )
  557. cookies = response.headers["Set-Cookie"]
  558. self.assertEqual(response.code, 200)
  559. response_obj = salt.utils.json.loads(response.body)["return"][0]
  560. token = response_obj["token"]
  561. self.assertIn("session_id={}".format(token), cookies)
  562. self.assertEqual(
  563. sorted(response_obj["perms"]),
  564. sorted(
  565. self.opts["external_auth"]["auto"][self.auth_creds_dict["username"]]
  566. ),
  567. )
  568. self.assertIn("token", response_obj) # TODO: verify that its valid?
  569. self.assertEqual(response_obj["user"], self.auth_creds_dict["username"])
  570. self.assertEqual(response_obj["eauth"], self.auth_creds_dict["eauth"])
  571. def test_login_missing_password(self):
  572. """
  573. Test logins with bad/missing passwords
  574. """
  575. bad_creds = []
  576. for key, val in self.auth_creds_dict.items():
  577. if key == "password":
  578. continue
  579. bad_creds.append((key, val))
  580. response = self.fetch(
  581. "/login",
  582. method="POST",
  583. body=urlencode(bad_creds),
  584. headers={"Content-Type": self.content_type_map["form"]},
  585. )
  586. self.assertEqual(response.code, 400)
  587. def test_login_bad_creds(self):
  588. """
  589. Test logins with bad/missing passwords
  590. """
  591. bad_creds = []
  592. for key, val in self.auth_creds_dict.items():
  593. if key == "username":
  594. val = val + "foo"
  595. if key == "eauth":
  596. val = "sharedsecret"
  597. bad_creds.append((key, val))
  598. response = self.fetch(
  599. "/login",
  600. method="POST",
  601. body=urlencode(bad_creds),
  602. headers={"Content-Type": self.content_type_map["form"]},
  603. )
  604. self.assertEqual(response.code, 401)
  605. def test_login_invalid_data_structure(self):
  606. """
  607. Test logins with either list or string JSON payload
  608. """
  609. response = self.fetch(
  610. "/login",
  611. method="POST",
  612. body=salt.utils.json.dumps(self.auth_creds),
  613. headers={"Content-Type": self.content_type_map["form"]},
  614. )
  615. self.assertEqual(response.code, 400)
  616. response = self.fetch(
  617. "/login",
  618. method="POST",
  619. body=salt.utils.json.dumps(42),
  620. headers={"Content-Type": self.content_type_map["form"]},
  621. )
  622. self.assertEqual(response.code, 400)
  623. response = self.fetch(
  624. "/login",
  625. method="POST",
  626. body=salt.utils.json.dumps("mystring42"),
  627. headers={"Content-Type": self.content_type_map["form"]},
  628. )
  629. self.assertEqual(response.code, 400)
  630. class TestSaltRunHandler(SaltnadoTestCase):
  631. def get_app(self):
  632. urls = [("/run", saltnado.RunSaltAPIHandler)]
  633. return self.build_tornado_app(urls)
  634. def test_authentication_exception_consistency(self):
  635. """
  636. Test consistency of authentication exception of each clients.
  637. """
  638. valid_response = {"return": ["Failed to authenticate"]}
  639. clients = ["local", "local_async", "runner", "runner_async"]
  640. request_lowstates = map(
  641. lambda client: {
  642. "client": client,
  643. "tgt": "*",
  644. "fun": "test.fib",
  645. "arg": ["10"],
  646. },
  647. clients,
  648. )
  649. for request_lowstate in request_lowstates:
  650. response = self.fetch(
  651. "/run",
  652. method="POST",
  653. body=salt.utils.json.dumps(request_lowstate),
  654. headers={"Content-Type": self.content_type_map["json"]},
  655. )
  656. self.assertEqual(valid_response, salt.utils.json.loads(response.body))
  657. @skipIf(
  658. not HAS_TORNADO, "The tornado package needs to be installed"
  659. ) # pylint: disable=W0223
  660. class TestWebsocketSaltAPIHandler(SaltnadoTestCase):
  661. def get_app(self):
  662. opts = copy.deepcopy(self.opts)
  663. opts.setdefault("rest_tornado", {})["websockets"] = True
  664. return rest_tornado.get_application(opts)
  665. @gen_test
  666. def test_websocket_handler_upgrade_to_websocket(self):
  667. response = yield self.http_client.fetch(
  668. self.get_url("/login"),
  669. method="POST",
  670. body=urlencode(self.auth_creds),
  671. headers={"Content-Type": self.content_type_map["form"]},
  672. )
  673. token = salt.utils.json.loads(self.decode_body(response).body)["return"][0][
  674. "token"
  675. ]
  676. url = "ws://127.0.0.1:{}/all_events/{}".format(self.get_http_port(), token)
  677. request = HTTPRequest(
  678. url, headers={"Origin": "http://example.com", "Host": "example.com"}
  679. )
  680. ws = yield websocket_connect(request)
  681. ws.write_message("websocket client ready")
  682. ws.close()
  683. @gen_test
  684. def test_websocket_handler_bad_token(self):
  685. """
  686. A bad token should returns a 401 during a websocket connect
  687. """
  688. token = "A" * len(
  689. getattr(hashlib, self.opts.get("hash_type", "md5"))().hexdigest()
  690. )
  691. url = "ws://127.0.0.1:{}/all_events/{}".format(self.get_http_port(), token)
  692. request = HTTPRequest(
  693. url, headers={"Origin": "http://example.com", "Host": "example.com"}
  694. )
  695. try:
  696. ws = yield websocket_connect(request)
  697. except HTTPError as error:
  698. self.assertEqual(error.code, 401)
  699. @gen_test
  700. def test_websocket_handler_cors_origin_wildcard(self):
  701. self._app.mod_opts["cors_origin"] = "*"
  702. response = yield self.http_client.fetch(
  703. self.get_url("/login"),
  704. method="POST",
  705. body=urlencode(self.auth_creds),
  706. headers={"Content-Type": self.content_type_map["form"]},
  707. )
  708. token = salt.utils.json.loads(self.decode_body(response).body)["return"][0][
  709. "token"
  710. ]
  711. url = "ws://127.0.0.1:{}/all_events/{}".format(self.get_http_port(), token)
  712. request = HTTPRequest(
  713. url, headers={"Origin": "http://foo.bar", "Host": "example.com"}
  714. )
  715. ws = yield websocket_connect(request)
  716. ws.write_message("websocket client ready")
  717. ws.close()
  718. @gen_test
  719. def test_cors_origin_single(self):
  720. self._app.mod_opts["cors_origin"] = "http://example.com"
  721. response = yield self.http_client.fetch(
  722. self.get_url("/login"),
  723. method="POST",
  724. body=urlencode(self.auth_creds),
  725. headers={"Content-Type": self.content_type_map["form"]},
  726. )
  727. token = salt.utils.json.loads(self.decode_body(response).body)["return"][0][
  728. "token"
  729. ]
  730. url = "ws://127.0.0.1:{}/all_events/{}".format(self.get_http_port(), token)
  731. # Example.com should works
  732. request = HTTPRequest(
  733. url, headers={"Origin": "http://example.com", "Host": "example.com"}
  734. )
  735. ws = yield websocket_connect(request)
  736. ws.write_message("websocket client ready")
  737. ws.close()
  738. # But foo.bar not
  739. request = HTTPRequest(
  740. url, headers={"Origin": "http://foo.bar", "Host": "example.com"}
  741. )
  742. try:
  743. ws = yield websocket_connect(request)
  744. except HTTPError as error:
  745. self.assertEqual(error.code, 403)
  746. @gen_test
  747. def test_cors_origin_multiple(self):
  748. self._app.mod_opts["cors_origin"] = ["http://example.com", "http://foo.bar"]
  749. response = yield self.http_client.fetch(
  750. self.get_url("/login"),
  751. method="POST",
  752. body=urlencode(self.auth_creds),
  753. headers={"Content-Type": self.content_type_map["form"]},
  754. )
  755. token = salt.utils.json.loads(self.decode_body(response).body)["return"][0][
  756. "token"
  757. ]
  758. url = "ws://127.0.0.1:{}/all_events/{}".format(self.get_http_port(), token)
  759. # Example.com should works
  760. request = HTTPRequest(
  761. url, headers={"Origin": "http://example.com", "Host": "example.com"}
  762. )
  763. ws = yield websocket_connect(request)
  764. ws.write_message("websocket client ready")
  765. ws.close()
  766. # Foo.bar too
  767. request = HTTPRequest(
  768. url, headers={"Origin": "http://foo.bar", "Host": "example.com"}
  769. )
  770. ws = yield websocket_connect(request)
  771. ws.write_message("websocket client ready")
  772. ws.close()
  773. @skipIf(not HAS_TORNADO, "The tornado package needs to be installed")
  774. class TestSaltnadoUtils(AsyncTestCase):
  775. def test_any_future(self):
  776. """
  777. Test that the Any Future does what we think it does
  778. """
  779. # create a few futures
  780. futures = []
  781. for x in range(0, 3):
  782. future = salt.ext.tornado.concurrent.Future()
  783. future.add_done_callback(self.stop)
  784. futures.append(future)
  785. # create an any future, make sure it isn't immediately done
  786. any_ = saltnado.Any(futures)
  787. self.assertIs(any_.done(), False)
  788. # finish one, lets see who finishes
  789. futures[0].set_result("foo")
  790. self.wait()
  791. self.assertIs(any_.done(), True)
  792. self.assertIs(futures[0].done(), True)
  793. self.assertIs(futures[1].done(), False)
  794. self.assertIs(futures[2].done(), False)
  795. # make sure it returned the one that finished
  796. self.assertEqual(any_.result(), futures[0])
  797. futures = futures[1:]
  798. # re-wait on some other futures
  799. any_ = saltnado.Any(futures)
  800. futures[0].set_result("foo")
  801. self.wait()
  802. self.assertIs(any_.done(), True)
  803. self.assertIs(futures[0].done(), True)
  804. self.assertIs(futures[1].done(), False)
  805. @skipIf(not HAS_TORNADO, "The tornado package needs to be installed")
  806. class TestEventListener(AsyncTestCase):
  807. def setUp(self):
  808. self.sock_dir = os.path.join(RUNTIME_VARS.TMP, "test-socks")
  809. if not os.path.exists(self.sock_dir):
  810. os.makedirs(self.sock_dir)
  811. self.addCleanup(shutil.rmtree, self.sock_dir, ignore_errors=True)
  812. super().setUp()
  813. @slowTest
  814. def test_simple(self):
  815. """
  816. Test getting a few events
  817. """
  818. with eventpublisher_process(self.sock_dir):
  819. me = salt.utils.event.MasterEvent(self.sock_dir)
  820. event_listener = saltnado.EventListener(
  821. {}, # we don't use mod_opts, don't save?
  822. {"sock_dir": self.sock_dir, "transport": "zeromq"},
  823. )
  824. self._finished = False # fit to event_listener's behavior
  825. event_future = event_listener.get_event(
  826. self, "evt1", callback=self.stop
  827. ) # get an event future
  828. me.fire_event({"data": "foo2"}, "evt2") # fire an event we don't want
  829. me.fire_event({"data": "foo1"}, "evt1") # fire an event we do want
  830. self.wait() # wait for the future
  831. # check that we got the event we wanted
  832. self.assertTrue(event_future.done())
  833. self.assertEqual(event_future.result()["tag"], "evt1")
  834. self.assertEqual(event_future.result()["data"]["data"], "foo1")
  835. @slowTest
  836. def test_set_event_handler(self):
  837. """
  838. Test subscribing events using set_event_handler
  839. """
  840. with eventpublisher_process(self.sock_dir):
  841. me = salt.utils.event.MasterEvent(self.sock_dir)
  842. event_listener = saltnado.EventListener(
  843. {}, # we don't use mod_opts, don't save?
  844. {"sock_dir": self.sock_dir, "transport": "zeromq"},
  845. )
  846. self._finished = False # fit to event_listener's behavior
  847. event_future = event_listener.get_event(
  848. self, tag="evt", callback=self.stop, timeout=1,
  849. ) # get an event future
  850. me.fire_event({"data": "foo"}, "evt") # fire an event we do want
  851. self.wait()
  852. # check that we subscribed the event we wanted
  853. self.assertEqual(len(event_listener.timeout_map), 0)
  854. @slowTest
  855. def test_timeout(self):
  856. """
  857. Make sure timeouts work correctly
  858. """
  859. with eventpublisher_process(self.sock_dir):
  860. event_listener = saltnado.EventListener(
  861. {}, # we don't use mod_opts, don't save?
  862. {"sock_dir": self.sock_dir, "transport": "zeromq"},
  863. )
  864. self._finished = False # fit to event_listener's behavior
  865. event_future = event_listener.get_event(
  866. self, tag="evt1", callback=self.stop, timeout=1,
  867. ) # get an event future
  868. self.wait()
  869. self.assertTrue(event_future.done())
  870. with self.assertRaises(saltnado.TimeoutException):
  871. event_future.result()
  872. @slowTest
  873. def test_clean_by_request(self):
  874. """
  875. Make sure the method clean_by_request clean up every related data in EventListener
  876. request_future_1 : will be timeout-ed by clean_by_request(self)
  877. request_future_2 : will be finished by me.fire_event ...
  878. dummy_request_future_1 : will be finished by me.fire_event ...
  879. dummy_request_future_2 : will be timeout-ed by clean-by_request(dummy_request)
  880. """
  881. class DummyRequest:
  882. """
  883. Dummy request object to simulate the request object
  884. """
  885. @property
  886. def _finished(self):
  887. """
  888. Simulate _finished of the request object
  889. """
  890. return False
  891. # Inner functions never permit modifying primitive values directly
  892. cnt = [0]
  893. def stop():
  894. """
  895. To realize the scenario of this test, define a custom stop method to call
  896. self.stop after finished two events.
  897. """
  898. cnt[0] += 1
  899. if cnt[0] == 2:
  900. self.stop()
  901. with eventpublisher_process(self.sock_dir):
  902. me = salt.utils.event.MasterEvent(self.sock_dir)
  903. event_listener = saltnado.EventListener(
  904. {}, # we don't use mod_opts, don't save?
  905. {"sock_dir": self.sock_dir, "transport": "zeromq"},
  906. )
  907. self.assertEqual(0, len(event_listener.tag_map))
  908. self.assertEqual(0, len(event_listener.request_map))
  909. self._finished = False # fit to event_listener's behavior
  910. dummy_request = DummyRequest()
  911. request_future_1 = event_listener.get_event(self, tag="evt1")
  912. request_future_2 = event_listener.get_event(
  913. self, tag="evt2", callback=lambda f: stop()
  914. )
  915. dummy_request_future_1 = event_listener.get_event(
  916. dummy_request, tag="evt3", callback=lambda f: stop()
  917. )
  918. dummy_request_future_2 = event_listener.get_event(
  919. dummy_request, timeout=10, tag="evt4"
  920. )
  921. self.assertEqual(4, len(event_listener.tag_map))
  922. self.assertEqual(2, len(event_listener.request_map))
  923. me.fire_event({"data": "foo2"}, "evt2")
  924. me.fire_event({"data": "foo3"}, "evt3")
  925. self.wait()
  926. event_listener.clean_by_request(self)
  927. me.fire_event({"data": "foo1"}, "evt1")
  928. self.assertTrue(request_future_1.done())
  929. with self.assertRaises(saltnado.TimeoutException):
  930. request_future_1.result()
  931. self.assertTrue(request_future_2.done())
  932. self.assertEqual(request_future_2.result()["tag"], "evt2")
  933. self.assertEqual(request_future_2.result()["data"]["data"], "foo2")
  934. self.assertTrue(dummy_request_future_1.done())
  935. self.assertEqual(dummy_request_future_1.result()["tag"], "evt3")
  936. self.assertEqual(dummy_request_future_1.result()["data"]["data"], "foo3")
  937. self.assertFalse(dummy_request_future_2.done())
  938. self.assertEqual(2, len(event_listener.tag_map))
  939. self.assertEqual(1, len(event_listener.request_map))
  940. event_listener.clean_by_request(dummy_request)
  941. with self.assertRaises(saltnado.TimeoutException):
  942. dummy_request_future_2.result()
  943. self.assertEqual(0, len(event_listener.tag_map))
  944. self.assertEqual(0, len(event_listener.request_map))