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.
 
 
 
 
 
 

541 lines
18 KiB

  1. # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
  2. # imports - standard imports
  3. import gzip
  4. import json
  5. import os
  6. import shlex
  7. import shutil
  8. import subprocess
  9. from typing import List
  10. import unittest
  11. import glob
  12. # imports - module imports
  13. import frappe
  14. import frappe.recorder
  15. from frappe.installer import add_to_installed_apps, remove_app
  16. from frappe.utils import add_to_date, get_bench_relative_path, now
  17. from frappe.utils.backups import fetch_latest_backups
  18. # imports - third party imports
  19. import click
  20. def clean(value) -> str:
  21. """Strips and converts bytes to str
  22. Args:
  23. value ([type]): [description]
  24. Returns:
  25. [type]: [description]
  26. """
  27. if isinstance(value, bytes):
  28. value = value.decode()
  29. if isinstance(value, str):
  30. value = value.strip()
  31. return value
  32. def missing_in_backup(doctypes: List, file: os.PathLike) -> List:
  33. """Returns list of missing doctypes in the backup.
  34. Args:
  35. doctypes (list): List of DocTypes to be checked
  36. file (str): Path of the database file
  37. Returns:
  38. doctypes(list): doctypes that are missing in backup
  39. """
  40. predicate = (
  41. 'COPY public."tab{}"'
  42. if frappe.conf.db_type == "postgres"
  43. else "CREATE TABLE `tab{}`"
  44. )
  45. with gzip.open(file, "rb") as f:
  46. content = f.read().decode("utf8").lower()
  47. return [doctype for doctype in doctypes
  48. if predicate.format(doctype).lower() not in content]
  49. def exists_in_backup(doctypes: List, file: os.PathLike) -> bool:
  50. """Checks if the list of doctypes exist in the database.sql.gz file supplied
  51. Args:
  52. doctypes (list): List of DocTypes to be checked
  53. file (str): Path of the database file
  54. Returns:
  55. bool: True if all tables exist
  56. """
  57. missing_doctypes = missing_in_backup(doctypes, file)
  58. return len(missing_doctypes) == 0
  59. class BaseTestCommands(unittest.TestCase):
  60. def execute(self, command, kwargs=None):
  61. site = {"site": frappe.local.site}
  62. cmd_input = None
  63. if kwargs:
  64. cmd_input = kwargs.get("cmd_input", None)
  65. if cmd_input:
  66. if not isinstance(cmd_input, bytes):
  67. raise Exception(
  68. f"The input should be of type bytes, not {type(cmd_input).__name__}"
  69. )
  70. del kwargs["cmd_input"]
  71. kwargs.update(site)
  72. else:
  73. kwargs = site
  74. self.command = " ".join(command.split()).format(**kwargs)
  75. click.secho(self.command, fg="bright_black")
  76. command = shlex.split(self.command)
  77. self._proc = subprocess.run(command, input=cmd_input, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  78. self.stdout = clean(self._proc.stdout)
  79. self.stderr = clean(self._proc.stderr)
  80. self.returncode = clean(self._proc.returncode)
  81. def _formatMessage(self, msg, standardMsg):
  82. output = super(BaseTestCommands, self)._formatMessage(msg, standardMsg)
  83. cmd_execution_summary = "\n".join([
  84. "-" * 70,
  85. "Last Command Execution Summary:",
  86. "Command: {}".format(self.command) if self.command else "",
  87. "Standard Output: {}".format(self.stdout) if self.stdout else "",
  88. "Standard Error: {}".format(self.stderr) if self.stderr else "",
  89. "Return Code: {}".format(self.returncode) if self.returncode else "",
  90. ]).strip()
  91. return "{}\n\n{}".format(output, cmd_execution_summary)
  92. class TestCommands(BaseTestCommands):
  93. def test_execute(self):
  94. # test 1: execute a command expecting a numeric output
  95. self.execute("bench --site {site} execute frappe.db.get_database_size")
  96. self.assertEqual(self.returncode, 0)
  97. self.assertIsInstance(float(self.stdout), float)
  98. # test 2: execute a command expecting an errored output as local won't exist
  99. self.execute("bench --site {site} execute frappe.local.site")
  100. self.assertEqual(self.returncode, 1)
  101. self.assertIsNotNone(self.stderr)
  102. # test 3: execute a command with kwargs
  103. # Note:
  104. # terminal command has been escaped to avoid .format string replacement
  105. # The returned value has quotes which have been trimmed for the test
  106. self.execute("""bench --site {site} execute frappe.bold --kwargs '{{"text": "DocType"}}'""")
  107. self.assertEqual(self.returncode, 0)
  108. self.assertEqual(self.stdout[1:-1], frappe.bold(text="DocType"))
  109. def test_backup(self):
  110. backup = {
  111. "includes": {
  112. "includes": [
  113. "ToDo",
  114. "Note",
  115. ]
  116. },
  117. "excludes": {
  118. "excludes": [
  119. "Activity Log",
  120. "Access Log",
  121. "Error Log"
  122. ]
  123. }
  124. }
  125. home = os.path.expanduser("~")
  126. site_backup_path = frappe.utils.get_site_path("private", "backups")
  127. # test 1: take a backup
  128. before_backup = fetch_latest_backups()
  129. self.execute("bench --site {site} backup")
  130. after_backup = fetch_latest_backups()
  131. self.assertEqual(self.returncode, 0)
  132. self.assertIn("successfully completed", self.stdout)
  133. self.assertNotEqual(before_backup["database"], after_backup["database"])
  134. # test 2: take a backup with --with-files
  135. before_backup = after_backup.copy()
  136. self.execute("bench --site {site} backup --with-files")
  137. after_backup = fetch_latest_backups()
  138. self.assertEqual(self.returncode, 0)
  139. self.assertIn("successfully completed", self.stdout)
  140. self.assertIn("with files", self.stdout)
  141. self.assertNotEqual(before_backup, after_backup)
  142. self.assertIsNotNone(after_backup["public"])
  143. self.assertIsNotNone(after_backup["private"])
  144. # test 3: take a backup with --backup-path
  145. backup_path = os.path.join(home, "backups")
  146. self.execute("bench --site {site} backup --backup-path {backup_path}", {"backup_path": backup_path})
  147. self.assertEqual(self.returncode, 0)
  148. self.assertTrue(os.path.exists(backup_path))
  149. self.assertGreaterEqual(len(os.listdir(backup_path)), 2)
  150. # test 4: take a backup with --backup-path-db, --backup-path-files, --backup-path-private-files, --backup-path-conf
  151. kwargs = {
  152. key: os.path.join(home, key, value)
  153. for key, value in {
  154. "db_path": "database.sql.gz",
  155. "files_path": "public.tar",
  156. "private_path": "private.tar",
  157. "conf_path": "config.json",
  158. }.items()
  159. }
  160. self.execute(
  161. """bench
  162. --site {site} backup --with-files
  163. --backup-path-db {db_path}
  164. --backup-path-files {files_path}
  165. --backup-path-private-files {private_path}
  166. --backup-path-conf {conf_path}""",
  167. kwargs,
  168. )
  169. self.assertEqual(self.returncode, 0)
  170. for path in kwargs.values():
  171. self.assertTrue(os.path.exists(path))
  172. # test 5: take a backup with --compress
  173. self.execute("bench --site {site} backup --with-files --compress")
  174. self.assertEqual(self.returncode, 0)
  175. compressed_files = glob.glob(site_backup_path + "/*.tgz")
  176. self.assertGreater(len(compressed_files), 0)
  177. # test 6: take a backup with --verbose
  178. self.execute("bench --site {site} backup --verbose")
  179. self.assertEqual(self.returncode, 0)
  180. # test 7: take a backup with frappe.conf.backup.includes
  181. self.execute(
  182. "bench --site {site} set-config backup '{includes}' --parse",
  183. {"includes": json.dumps(backup["includes"])},
  184. )
  185. self.execute("bench --site {site} backup --verbose")
  186. self.assertEqual(self.returncode, 0)
  187. database = fetch_latest_backups(partial=True)["database"]
  188. self.assertEqual([], missing_in_backup(backup["includes"]["includes"], database))
  189. # test 8: take a backup with frappe.conf.backup.excludes
  190. self.execute(
  191. "bench --site {site} set-config backup '{excludes}' --parse",
  192. {"excludes": json.dumps(backup["excludes"])},
  193. )
  194. self.execute("bench --site {site} backup --verbose")
  195. self.assertEqual(self.returncode, 0)
  196. database = fetch_latest_backups(partial=True)["database"]
  197. self.assertFalse(exists_in_backup(backup["excludes"]["excludes"], database))
  198. self.assertEqual([], missing_in_backup(backup["includes"]["includes"], database))
  199. # test 9: take a backup with --include (with frappe.conf.excludes still set)
  200. self.execute(
  201. "bench --site {site} backup --include '{include}'",
  202. {"include": ",".join(backup["includes"]["includes"])},
  203. )
  204. self.assertEqual(self.returncode, 0)
  205. database = fetch_latest_backups(partial=True)["database"]
  206. self.assertEqual([], missing_in_backup(backup["includes"]["includes"], database))
  207. # test 10: take a backup with --exclude
  208. self.execute(
  209. "bench --site {site} backup --exclude '{exclude}'",
  210. {"exclude": ",".join(backup["excludes"]["excludes"])},
  211. )
  212. self.assertEqual(self.returncode, 0)
  213. database = fetch_latest_backups(partial=True)["database"]
  214. self.assertFalse(exists_in_backup(backup["excludes"]["excludes"], database))
  215. # test 11: take a backup with --ignore-backup-conf
  216. self.execute("bench --site {site} backup --ignore-backup-conf")
  217. self.assertEqual(self.returncode, 0)
  218. database = fetch_latest_backups()["database"]
  219. self.assertEqual([], missing_in_backup(backup["excludes"]["excludes"], database))
  220. def test_restore(self):
  221. # step 0: create a site to run the test on
  222. global_config = {
  223. "admin_password": frappe.conf.admin_password,
  224. "root_login": frappe.conf.root_login,
  225. "root_password": frappe.conf.root_password,
  226. "db_type": frappe.conf.db_type,
  227. }
  228. site_data = {"another_site": f"{frappe.local.site}-restore.test", **global_config}
  229. for key, value in global_config.items():
  230. if value:
  231. self.execute(f"bench set-config {key} {value} -g")
  232. self.execute(
  233. "bench new-site {another_site} --admin-password {admin_password} --db-type"
  234. " {db_type}",
  235. site_data,
  236. )
  237. # test 1: bench restore from full backup
  238. self.execute("bench --site {another_site} backup --ignore-backup-conf", site_data)
  239. self.execute(
  240. "bench --site {another_site} execute frappe.utils.backups.fetch_latest_backups",
  241. site_data,
  242. )
  243. site_data.update({"database": json.loads(self.stdout)["database"]})
  244. self.execute("bench --site {another_site} restore {database}", site_data)
  245. # test 2: restore from partial backup
  246. self.execute("bench --site {another_site} backup --exclude 'ToDo'", site_data)
  247. site_data.update({"kw": "\"{'partial':True}\""})
  248. self.execute(
  249. "bench --site {another_site} execute"
  250. " frappe.utils.backups.fetch_latest_backups --kwargs {kw}",
  251. site_data,
  252. )
  253. site_data.update({"database": json.loads(self.stdout)["database"]})
  254. self.execute("bench --site {another_site} restore {database}", site_data)
  255. self.assertEqual(self.returncode, 1)
  256. def test_partial_restore(self):
  257. _now = now()
  258. for num in range(10):
  259. frappe.get_doc({
  260. "doctype": "ToDo",
  261. "date": add_to_date(_now, days=num),
  262. "description": frappe.mock("paragraph")
  263. }).insert()
  264. frappe.db.commit()
  265. todo_count = frappe.db.count("ToDo")
  266. # check if todos exist, create a partial backup and see if the state is the same after restore
  267. self.assertIsNot(todo_count, 0)
  268. self.execute("bench --site {site} backup --only 'ToDo'")
  269. db_path = fetch_latest_backups(partial=True)["database"]
  270. self.assertTrue("partial" in db_path)
  271. frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabToDo`")
  272. frappe.db.commit()
  273. self.execute("bench --site {site} partial-restore {path}", {"path": db_path})
  274. self.assertEqual(self.returncode, 0)
  275. self.assertEqual(frappe.db.count("ToDo"), todo_count)
  276. def test_recorder(self):
  277. frappe.recorder.stop()
  278. self.execute("bench --site {site} start-recording")
  279. frappe.local.cache = {}
  280. self.assertEqual(frappe.recorder.status(), True)
  281. self.execute("bench --site {site} stop-recording")
  282. frappe.local.cache = {}
  283. self.assertEqual(frappe.recorder.status(), False)
  284. def test_remove_from_installed_apps(self):
  285. app = "test_remove_app"
  286. add_to_installed_apps(app)
  287. # check: confirm that add_to_installed_apps added the app in the default
  288. self.execute("bench --site {site} list-apps")
  289. self.assertIn(app, self.stdout)
  290. # test 1: remove app from installed_apps global default
  291. self.execute("bench --site {site} remove-from-installed-apps {app}", {"app": app})
  292. self.assertEqual(self.returncode, 0)
  293. self.execute("bench --site {site} list-apps")
  294. self.assertNotIn(app, self.stdout)
  295. def test_list_apps(self):
  296. # test 1: sanity check for command
  297. self.execute("bench --site all list-apps")
  298. self.assertEqual(self.returncode, 0)
  299. # test 2: bare functionality for single site
  300. self.execute("bench --site {site} list-apps")
  301. self.assertEqual(self.returncode, 0)
  302. list_apps = set(
  303. _x.split()[0] for _x in self.stdout.split("\n")
  304. )
  305. doctype = frappe.get_single("Installed Applications").installed_applications
  306. if doctype:
  307. installed_apps = set(x.app_name for x in doctype)
  308. else:
  309. installed_apps = set(frappe.get_installed_apps())
  310. self.assertSetEqual(list_apps, installed_apps)
  311. # test 3: parse json format
  312. self.execute("bench --site all list-apps --format json")
  313. self.assertEqual(self.returncode, 0)
  314. self.assertIsInstance(json.loads(self.stdout), dict)
  315. self.execute("bench --site {site} list-apps --format json")
  316. self.assertIsInstance(json.loads(self.stdout), dict)
  317. self.execute("bench --site {site} list-apps -f json")
  318. self.assertIsInstance(json.loads(self.stdout), dict)
  319. def test_show_config(self):
  320. # test 1: sanity check for command
  321. self.execute("bench --site all show-config")
  322. self.assertEqual(self.returncode, 0)
  323. # test 2: test keys in table text
  324. self.execute(
  325. "bench --site {site} set-config test_key '{second_order}' --parse",
  326. {"second_order": json.dumps({"test_key": "test_value"})},
  327. )
  328. self.execute("bench --site {site} show-config")
  329. self.assertEqual(self.returncode, 0)
  330. self.assertIn("test_key.test_key", self.stdout.split())
  331. self.assertIn("test_value", self.stdout.split())
  332. # test 3: parse json format
  333. self.execute("bench --site all show-config --format json")
  334. self.assertEqual(self.returncode, 0)
  335. self.assertIsInstance(json.loads(self.stdout), dict)
  336. self.execute("bench --site {site} show-config --format json")
  337. self.assertIsInstance(json.loads(self.stdout), dict)
  338. self.execute("bench --site {site} show-config -f json")
  339. self.assertIsInstance(json.loads(self.stdout), dict)
  340. def test_get_bench_relative_path(self):
  341. bench_path = frappe.utils.get_bench_path()
  342. test1_path = os.path.join(bench_path, "test1.txt")
  343. test2_path = os.path.join(bench_path, "sites", "test2.txt")
  344. with open(test1_path, "w+") as test1:
  345. test1.write("asdf")
  346. with open(test2_path, "w+") as test2:
  347. test2.write("asdf")
  348. self.assertTrue("test1.txt" in get_bench_relative_path("test1.txt"))
  349. self.assertTrue("sites/test2.txt" in get_bench_relative_path("test2.txt"))
  350. with self.assertRaises(SystemExit):
  351. get_bench_relative_path("test3.txt")
  352. os.remove(test1_path)
  353. os.remove(test2_path)
  354. def test_frappe_site_env(self):
  355. os.putenv('FRAPPE_SITE', frappe.local.site)
  356. self.execute("bench execute frappe.ping")
  357. self.assertEqual(self.returncode, 0)
  358. self.assertIn("pong", self.stdout)
  359. def test_version(self):
  360. self.execute("bench version")
  361. self.assertEqual(self.returncode, 0)
  362. for output in ["legacy", "plain", "table", "json"]:
  363. self.execute(f"bench version -f {output}")
  364. self.assertEqual(self.returncode, 0)
  365. self.execute("bench version -f invalid")
  366. self.assertEqual(self.returncode, 2)
  367. def test_set_password(self):
  368. from frappe.utils.password import check_password
  369. self.execute("bench --site {site} set-password Administrator test1")
  370. self.assertEqual(self.returncode, 0)
  371. self.assertEqual(check_password('Administrator', 'test1'), 'Administrator')
  372. # to release the lock taken by check_password
  373. frappe.db.commit()
  374. self.execute("bench --site {site} set-admin-password test2")
  375. self.assertEqual(self.returncode, 0)
  376. self.assertEqual(check_password('Administrator', 'test2'), 'Administrator')
  377. def test_make_app(self):
  378. user_input = [
  379. b"Test App", # title
  380. b"This app's description contains 'single quotes' and \"double quotes\".", # description
  381. b"Test Publisher", # publisher
  382. b"example@example.org", # email
  383. b"", # icon
  384. b"", # color
  385. b"MIT" # app_license
  386. ]
  387. app_name = "testapp0"
  388. apps_path = os.path.join(frappe.utils.get_bench_path(), "apps")
  389. test_app_path = os.path.join(apps_path, app_name)
  390. self.execute(f"bench make-app {apps_path} {app_name}", {"cmd_input": b'\n'.join(user_input)})
  391. self.assertEqual(self.returncode, 0)
  392. self.assertTrue(
  393. os.path.exists(test_app_path)
  394. )
  395. # cleanup
  396. shutil.rmtree(test_app_path)
  397. def disable_test_bench_drop_site_should_archive_site(self):
  398. site = 'test_site.localhost'
  399. self.execute(
  400. f"bench new-site {site} --force --verbose --admin-password {frappe.conf.admin_password} "
  401. f"--mariadb-root-password {frappe.conf.root_password}"
  402. )
  403. self.assertEqual(self.returncode, 0)
  404. self.execute(f"bench drop-site {site} --force --root-password {frappe.conf.root_password}")
  405. self.assertEqual(self.returncode, 0)
  406. bench_path = frappe.utils.get_bench_path()
  407. site_directory = os.path.join(bench_path, f'sites/{site}')
  408. self.assertFalse(os.path.exists(site_directory))
  409. archive_directory = os.path.join(bench_path, f'archived/sites/{site}')
  410. self.assertTrue(os.path.exists(archive_directory))
  411. class RemoveAppUnitTests(unittest.TestCase):
  412. def test_delete_modules(self):
  413. from frappe.installer import (
  414. _delete_doctypes,
  415. _delete_modules,
  416. _get_module_linked_doctype_field_map,
  417. )
  418. test_module = frappe.new_doc("Module Def")
  419. test_module.update({"module_name": "RemoveThis", "app_name": "frappe"})
  420. test_module.save()
  421. module_def_linked_doctype = frappe.get_doc({
  422. "doctype": "DocType",
  423. "name": "Doctype linked with module def",
  424. "module": "RemoveThis",
  425. "custom": 1,
  426. "fields": [{
  427. "label": "Modulen't",
  428. "fieldname": "notmodule",
  429. "fieldtype": "Link",
  430. "options": "Module Def"
  431. }]
  432. }).insert()
  433. doctype_to_link_field_map = _get_module_linked_doctype_field_map()
  434. self.assertIn("Report", doctype_to_link_field_map)
  435. self.assertIn(module_def_linked_doctype.name, doctype_to_link_field_map)
  436. self.assertEqual(doctype_to_link_field_map[module_def_linked_doctype.name], "notmodule")
  437. self.assertNotIn("DocType", doctype_to_link_field_map)
  438. doctypes_to_delete = _delete_modules([test_module.module_name], dry_run=False)
  439. self.assertEqual(len(doctypes_to_delete), 1)
  440. _delete_doctypes(doctypes_to_delete, dry_run=False)
  441. self.assertFalse(frappe.db.exists("Module Def", test_module.module_name))
  442. self.assertFalse(frappe.db.exists("DocType", module_def_linked_doctype.name))
  443. def test_dry_run(self):
  444. """Check if dry run in not destructive."""
  445. # nothing to assert, if this fails rest of the test suite will crumble.
  446. remove_app("frappe", dry_run=True, yes=True, no_backup=True)