b10-cmdctl-usermgr_test.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. # Copyright (C) 2013 Internet Systems Consortium.
  2. #
  3. # Permission to use, copy, modify, and distribute this software for any
  4. # purpose with or without fee is hereby granted, provided that the above
  5. # copyright notice and this permission notice appear in all copies.
  6. #
  7. # THE SOFTWARE IS PROVIDED "AS IS" AND INTERNET SYSTEMS CONSORTIUM
  8. # DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
  9. # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL
  10. # INTERNET SYSTEMS CONSORTIUM BE LIABLE FOR ANY SPECIAL, DIRECT,
  11. # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING
  12. # FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN COMMAND OF CONTRACT,
  13. # NEGLIGENCE OR OTHER TORTIOUS COMMAND, ARISING OUT OF OR IN CONNECTION
  14. # WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  15. import csv
  16. from hashlib import sha1
  17. import getpass
  18. import imp
  19. import os
  20. import subprocess
  21. import stat
  22. import sys
  23. import unittest
  24. from bind10_config import SYSCONFPATH
  25. class PrintCatcher:
  26. def __init__(self):
  27. self.stdout_lines = []
  28. def __enter__(self):
  29. self.__orig_stdout_write = sys.stdout.write
  30. def new_write(line):
  31. self.stdout_lines.append(line)
  32. sys.stdout.write = new_write
  33. return self
  34. def __exit__(self, type, value, traceback):
  35. sys.stdout.write = self.__orig_stdout_write
  36. class OverrideGetpass:
  37. def __init__(self, new_getpass):
  38. self.__new_getpass = new_getpass
  39. self.__orig_getpass = getpass.getpass
  40. def __enter__(self):
  41. getpass.getpass = self.__new_getpass
  42. return self
  43. def __exit__(self, type, value, traceback):
  44. getpass.getpass = self.__orig_getpass
  45. # input() is a built-in function and not easily overridable
  46. # so this one uses usermgr for that
  47. class OverrideInput:
  48. def __init__(self, usermgr, new_getpass):
  49. self.__usermgr = usermgr
  50. self.__new_input = new_getpass
  51. self.__orig_input = usermgr._input
  52. def __enter__(self):
  53. self.__usermgr._input = self.__new_input
  54. return self
  55. def __exit__(self, type, value, traceback):
  56. self.__usermgr._input = self.__orig_input
  57. def run(command):
  58. """
  59. Small helper function that returns a tuple of (rcode, stdout, stderr)
  60. after running the given command (an array of command and arguments, as
  61. passed on to subprocess).
  62. Parameters:
  63. command: an array of command and argument strings, which will be
  64. passed to subprocess.Popen()
  65. """
  66. subp = subprocess.Popen(command, stdout=subprocess.PIPE,
  67. stderr=subprocess.PIPE)
  68. (stdout, stderr) = subp.communicate()
  69. return (subp.returncode, stdout, stderr)
  70. class TestUserMgr(unittest.TestCase):
  71. TOOL = '../b10-cmdctl-usermgr'
  72. OUTPUT_FILE = 'test_users.csv'
  73. def setUp(self):
  74. self.delete_output_file()
  75. # For access to the actual module, we load it directly
  76. self.usermgr_module = imp.load_source('usermgr',
  77. '../b10-cmdctl-usermgr.py')
  78. # And instantiate 1 instance (with fake options/args)
  79. self.usermgr = self.usermgr_module.UserManager(object(), object())
  80. def tearDown(self):
  81. self.delete_output_file()
  82. def delete_output_file(self):
  83. if os.path.exists(self.OUTPUT_FILE):
  84. os.remove(self.OUTPUT_FILE)
  85. def check_output_file(self, expected_content):
  86. self.assertTrue(os.path.exists(self.OUTPUT_FILE))
  87. csv_entries = []
  88. with open(self.OUTPUT_FILE, newline='') as csvfile:
  89. reader = csv.reader(csvfile)
  90. csv_entries = [row for row in reader]
  91. self.assertEqual(len(expected_content), len(csv_entries))
  92. csv_entries.reverse()
  93. for expected_entry in expected_content:
  94. expected_name = expected_entry[0]
  95. expected_pass = expected_entry[1]
  96. csv_entry = csv_entries.pop()
  97. entry_name = csv_entry[0]
  98. entry_salt = csv_entry[2]
  99. entry_hash = csv_entry[1]
  100. self.assertEqual(expected_name, entry_name)
  101. expected_hash =\
  102. sha1((expected_pass + entry_salt).encode()).hexdigest()
  103. self.assertEqual(expected_hash, entry_hash)
  104. def run_check(self, expected_returncode, expected_stdout, expected_stderr,
  105. command):
  106. """
  107. Runs the given command, and checks return code, and outputs (if provided).
  108. Arguments:
  109. expected_returncode, return code of the command
  110. expected_stdout, (multiline) string that is checked against stdout.
  111. May be None, in which case the check is skipped.
  112. expected_stderr, (multiline) string that is checked against stderr.
  113. May be None, in which case the check is skipped.
  114. """
  115. (returncode, stdout, stderr) = run(command)
  116. if expected_stderr is not None:
  117. self.assertEqual(expected_stderr, stderr.decode())
  118. if expected_stdout is not None:
  119. self.assertEqual(expected_stdout, stdout.decode())
  120. self.assertEqual(expected_returncode, returncode, " ".join(command))
  121. def test_help(self):
  122. self.run_check(0,
  123. '''Usage: b10-cmdctl-usermgr [options] <command> [username] [password]
  124. Arguments:
  125. command either 'add' or 'delete'
  126. username the username to add or delete
  127. password the password to set for the added user
  128. If username or password are not specified, b10-cmdctl-usermgr will
  129. prompt for them. It is recommended practice to let the
  130. tool prompt for the password, as command-line
  131. arguments can be visible through history or process
  132. viewers.
  133. Options:
  134. --version show program's version number and exit
  135. -h, --help show this help message and exit
  136. -f OUTPUT_FILE, --file=OUTPUT_FILE
  137. Accounts file to modify
  138. -q, --quiet Quiet mode, don't print any output
  139. ''',
  140. '',
  141. [self.TOOL, '-h'])
  142. def test_add_delete_users_ok(self):
  143. """
  144. Test that a file is created, and users are added.
  145. Also tests quiet mode for adding a user to an existing file.
  146. """
  147. # content is a list of (user, pass) tuples
  148. expected_content = []
  149. # Creating a file
  150. self.run_check(0,
  151. 'Using accounts file: test_users.csv\n',
  152. '',
  153. [ self.TOOL,
  154. '-f', self.OUTPUT_FILE,
  155. 'add', 'user1', 'pass1'
  156. ])
  157. expected_content.append(('user1', 'pass1'))
  158. self.check_output_file(expected_content)
  159. # Add to existing file
  160. self.run_check(0,
  161. 'Using accounts file: test_users.csv\n',
  162. '',
  163. [ self.TOOL,
  164. '-f', self.OUTPUT_FILE,
  165. 'add', 'user2', 'pass2'
  166. ])
  167. expected_content.append(('user2', 'pass2'))
  168. self.check_output_file(expected_content)
  169. # Quiet mode
  170. self.run_check(0,
  171. '',
  172. '',
  173. [ self.TOOL, '-q',
  174. '-f', self.OUTPUT_FILE,
  175. 'add', 'user3', 'pass3'
  176. ])
  177. expected_content.append(('user3', 'pass3'))
  178. self.check_output_file(expected_content)
  179. # Delete a user (let's pick the middle one)
  180. self.run_check(0,
  181. '',
  182. '',
  183. [ self.TOOL, '-q',
  184. '-f', self.OUTPUT_FILE,
  185. 'delete', 'user2'
  186. ])
  187. del expected_content[1]
  188. self.check_output_file(expected_content)
  189. def test_add_delete_users_bad(self):
  190. """
  191. More add/delete tests, this time for some error scenarios
  192. """
  193. # content is a list of (user, pass) tuples
  194. expected_content = []
  195. # First add one
  196. self.run_check(0, None, None,
  197. [ self.TOOL,
  198. '-f', self.OUTPUT_FILE,
  199. 'add', 'user', 'pass'
  200. ])
  201. expected_content.append(('user', 'pass'))
  202. self.check_output_file(expected_content)
  203. # Adding it again should error
  204. self.run_check(3,
  205. 'Using accounts file: test_users.csv\n'
  206. 'Error: username exists\n',
  207. '',
  208. [ self.TOOL,
  209. '-f', self.OUTPUT_FILE,
  210. 'add', 'user', 'pass'
  211. ])
  212. self.check_output_file(expected_content)
  213. # Deleting a non-existent one should fail too
  214. self.run_check(4,
  215. 'Using accounts file: test_users.csv\n'
  216. 'Error: username does not exist\n',
  217. '',
  218. [ self.TOOL,
  219. '-f', self.OUTPUT_FILE,
  220. 'delete', 'nosuchuser'
  221. ])
  222. self.check_output_file(expected_content)
  223. def test_bad_arguments(self):
  224. """
  225. Assorted tests with bad command-line arguments
  226. """
  227. self.run_check(1,
  228. 'Error: no command specified\n',
  229. '',
  230. [ self.TOOL ])
  231. self.run_check(1,
  232. 'Error: command must be either add or delete\n',
  233. '',
  234. [ self.TOOL, 'foo' ])
  235. self.run_check(1,
  236. 'Error: extraneous arguments\n',
  237. '',
  238. [ self.TOOL, 'add', 'user', 'pass', 'toomuch' ])
  239. self.run_check(1,
  240. 'Error: delete only needs username, not a password\n',
  241. '',
  242. [ self.TOOL, 'delete', 'user', 'pass' ])
  243. def test_default_file(self):
  244. """
  245. Check the default file is the correct one.
  246. """
  247. # Hardcoded path .. should be ok since this is run from make check
  248. self.assertEqual(SYSCONFPATH + '/cmdctl-accounts.csv',
  249. self.usermgr_module.DEFAULT_FILE)
  250. def test_prompt_for_password_different(self):
  251. """
  252. Check that the method that prompts for a password verifies that
  253. the same value is entered twice
  254. """
  255. # returns a different string (the representation of the number
  256. # of times it has been called), until it has been called
  257. # over 10 times, in which case it will always return "11"
  258. getpass_different_called = 0
  259. def getpass_different(question):
  260. nonlocal getpass_different_called
  261. getpass_different_called += 1
  262. if getpass_different_called > 10:
  263. return "11"
  264. else:
  265. return str(getpass_different_called)
  266. with PrintCatcher() as pc:
  267. with OverrideGetpass(getpass_different):
  268. pwd = self.usermgr._prompt_for_password()
  269. self.assertEqual(12, getpass_different_called)
  270. self.assertEqual("11", pwd)
  271. # stdout should be 5 times the no match string;
  272. expected_output = "passwords do not match, try again\n"*5
  273. self.assertEqual(expected_output, ''.join(pc.stdout_lines))
  274. def test_prompt_for_password_empty(self):
  275. """
  276. Check that the method that prompts for a password verifies that
  277. the value entered is not empty
  278. """
  279. # returns an empty string until it has been called over 10
  280. # times
  281. getpass_empty_called = 0
  282. def getpass_empty(prompt):
  283. nonlocal getpass_empty_called
  284. getpass_empty_called += 1
  285. if getpass_empty_called > 10:
  286. return "nonempty"
  287. else:
  288. return ""
  289. with PrintCatcher() as pc:
  290. with OverrideGetpass(getpass_empty):
  291. pwd = self.usermgr._prompt_for_password()
  292. self.assertEqual("nonempty", pwd)
  293. self.assertEqual(12, getpass_empty_called)
  294. # stdout should be 10 times the 'cannot be empty' string
  295. expected_output = "Error: password cannot be empty\n"*10
  296. self.assertEqual(expected_output, ''.join(pc.stdout_lines))
  297. def test_prompt_for_user(self):
  298. """
  299. Test that the method that prompts for a username verifies that
  300. is not empty, and that it exists (or does not, depending on the
  301. action that is specified)
  302. """
  303. new_input_called = 0
  304. input_results = [ '', '', 'existinguser', 'nonexistinguser',
  305. '', '', 'nonexistinguser', 'existinguser' ]
  306. def new_input(prompt):
  307. nonlocal new_input_called
  308. if new_input_called < len(input_results):
  309. result = input_results[new_input_called]
  310. else:
  311. result = 'empty'
  312. new_input_called += 1
  313. return result
  314. # add fake user (value doesn't matter, method only checks for key)
  315. self.usermgr.user_info = { 'existinguser': None }
  316. expected_output = ''
  317. with PrintCatcher() as pc:
  318. with OverrideInput(self.usermgr, new_input):
  319. # should skip the first three since empty or existing
  320. # are not allowed, then return 'nonexistinguser'
  321. username = self.usermgr._prompt_for_username(
  322. self.usermgr_module.COMMAND_ADD)
  323. self.assertEqual('nonexistinguser', username)
  324. expected_output += "Error username can't be empty\n"*2
  325. expected_output += "user already exists\n"
  326. self.assertEqual(expected_output, ''.join(pc.stdout_lines))
  327. # For delete, should again not accept empty (in a while true
  328. # loop), and this time should not accept nonexisting users
  329. username = self.usermgr._prompt_for_username(
  330. self.usermgr_module.COMMAND_DELETE)
  331. self.assertEqual('existinguser', username)
  332. expected_output += "Error username can't be empty\n"*2
  333. expected_output += "user does not exist\n"
  334. self.assertEqual(expected_output, ''.join(pc.stdout_lines))
  335. def test_bad_file(self):
  336. """
  337. Check for graceful handling of bad file argument
  338. """
  339. self.run_check(2,
  340. 'Using accounts file: /\n'
  341. 'Error accessing /: Is a directory\n',
  342. '',
  343. [ self.TOOL, '-f', '/', 'add', 'user', 'pass' ])
  344. # Make sure we can initially write to the test file
  345. self.run_check(0, None, None,
  346. [ self.TOOL,
  347. '-f', self.OUTPUT_FILE,
  348. 'add', 'user1', 'pass1'
  349. ])
  350. # Make it non-writable (don't worry about cleanup, the
  351. # file should be deleted after each test anyway
  352. os.chmod(self.OUTPUT_FILE, stat.S_IRUSR)
  353. self.run_check(2,
  354. 'Using accounts file: test_users.csv\n'
  355. 'Error accessing test_users.csv: Permission denied\n',
  356. '',
  357. [ self.TOOL,
  358. '-f', self.OUTPUT_FILE,
  359. 'add', 'user2', 'pass1'
  360. ])
  361. self.run_check(2,
  362. 'Using accounts file: test_users.csv\n'
  363. 'Error accessing test_users.csv: Permission denied\n',
  364. '',
  365. [ self.TOOL,
  366. '-f', self.OUTPUT_FILE,
  367. 'delete', 'user1'
  368. ])
  369. # Making it write-only should have the same effect
  370. os.chmod(self.OUTPUT_FILE, stat.S_IWUSR)
  371. self.run_check(2,
  372. 'Using accounts file: test_users.csv\n'
  373. 'Error accessing test_users.csv: Permission denied\n',
  374. '',
  375. [ self.TOOL,
  376. '-f', self.OUTPUT_FILE,
  377. 'add', 'user2', 'pass1'
  378. ])
  379. self.run_check(2,
  380. 'Using accounts file: test_users.csv\n'
  381. 'Error accessing test_users.csv: Permission denied\n',
  382. '',
  383. [ self.TOOL,
  384. '-f', self.OUTPUT_FILE,
  385. 'delete', 'user1'
  386. ])
  387. def test_missing_fields(self):
  388. """
  389. Test that an invalid csv file is handled gracefully
  390. """
  391. # Valid but incomplete csv; should be handled
  392. # correctly
  393. with open(self.OUTPUT_FILE, 'w', newline='') as f:
  394. f.write('onlyuserfield\n')
  395. f.write('userfield,saltfield\n')
  396. f.write(',emptyuserfield,passwordfield\n')
  397. self.run_check(0, None, None,
  398. [ self.TOOL,
  399. '-f', self.OUTPUT_FILE,
  400. 'add', 'user1', 'pass1'
  401. ])
  402. self.run_check(0, None, None,
  403. [ self.TOOL,
  404. '-f', self.OUTPUT_FILE,
  405. 'delete', 'onlyuserfield'
  406. ])
  407. self.run_check(0, None, None,
  408. [ self.TOOL,
  409. '-f', self.OUTPUT_FILE,
  410. 'delete', ''
  411. ])
  412. def test_bad_data(self):
  413. # I can only think of one invalid format, an unclosed string
  414. with open(self.OUTPUT_FILE, 'w', newline='') as f:
  415. f.write('a,"\n')
  416. self.run_check(2,
  417. 'Using accounts file: test_users.csv\n'
  418. 'Error parsing csv file: newline inside string\n',
  419. '',
  420. [ self.TOOL,
  421. '-f', self.OUTPUT_FILE,
  422. 'add', 'user1', 'pass1'
  423. ])
  424. if __name__== '__main__':
  425. unittest.main()