conftest.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import os
  2. from typing import MutableMapping
  3. import pytest
  4. from django.db import connections
  5. from sentry.silo import SiloMode
  6. pytest_plugins = ["sentry.utils.pytest"]
  7. # XXX: The below code is vendored code from https://github.com/utgwkk/pytest-github-actions-annotate-failures
  8. # so that we can add support for pytest_rerunfailures
  9. # retried tests will no longer be annotated in GHA
  10. #
  11. # Reference:
  12. # https://docs.pytest.org/en/latest/writing_plugins.html#hookwrapper-executing-around-other-hooks
  13. # https://docs.pytest.org/en/latest/writing_plugins.html#hook-function-ordering-call-example
  14. # https://docs.pytest.org/en/stable/reference.html#pytest.hookspec.pytest_runtest_makereport
  15. #
  16. # Inspired by:
  17. # https://github.com/pytest-dev/pytest/blob/master/src/_pytest/terminal.py
  18. @pytest.hookimpl(tryfirst=True, hookwrapper=True)
  19. def pytest_runtest_makereport(item, call):
  20. # execute all other hooks to obtain the report object
  21. outcome = yield
  22. report = outcome.get_result()
  23. # enable only in a workflow of GitHub Actions
  24. # ref: https://help.github.com/en/actions/configuring-and-managing-workflows/using-environment-variables#default-environment-variables
  25. if os.environ.get("GITHUB_ACTIONS") != "true":
  26. return
  27. # If we have the pytest_rerunfailures plugin,
  28. # and there are still retries to be run,
  29. # then do not return the error
  30. if hasattr(item, "execution_count"):
  31. import pytest_rerunfailures
  32. if item.execution_count <= pytest_rerunfailures.get_reruns_count(item):
  33. return
  34. if report.when == "call" and report.failed:
  35. # collect information to be annotated
  36. filesystempath, lineno, _ = report.location
  37. # try to convert to absolute path in GitHub Actions
  38. workspace = os.environ.get("GITHUB_WORKSPACE")
  39. if workspace:
  40. full_path = os.path.abspath(filesystempath)
  41. try:
  42. rel_path = os.path.relpath(full_path, workspace)
  43. except ValueError:
  44. # os.path.relpath() will raise ValueError on Windows
  45. # when full_path and workspace have different mount points.
  46. # https://github.com/utgwkk/pytest-github-actions-annotate-failures/issues/20
  47. rel_path = filesystempath
  48. if not rel_path.startswith(".."):
  49. filesystempath = rel_path
  50. if lineno is not None:
  51. # 0-index to 1-index
  52. lineno += 1
  53. # get the name of the current failed test, with parametrize info
  54. longrepr = report.head_line or item.name
  55. # get the error message and line number from the actual error
  56. try:
  57. longrepr += "\n\n" + report.longrepr.reprcrash.message
  58. lineno = report.longrepr.reprcrash.lineno
  59. except AttributeError:
  60. pass
  61. print(_error_workflow_command(filesystempath, lineno, longrepr)) # noqa: S002
  62. def _error_workflow_command(filesystempath, lineno, longrepr):
  63. # Build collection of arguments. Ordering is strict for easy testing
  64. details_dict = {"file": filesystempath}
  65. if lineno is not None:
  66. details_dict["line"] = lineno
  67. details = ",".join(f"{k}={v}" for k, v in details_dict.items())
  68. if longrepr is None:
  69. return f"\n::error {details}"
  70. else:
  71. longrepr = _escape(longrepr)
  72. return f"\n::error {details}::{longrepr}"
  73. def _escape(s):
  74. return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
  75. _MODEL_MANIFEST_FILE_PATH = "./model-manifest.json" # os.getenv("SENTRY_MODEL_MANIFEST_FILE_PATH")
  76. _model_manifest = None
  77. @pytest.fixture(scope="session", autouse=True)
  78. def create_model_manifest_file():
  79. """Audit which models are touched by each test case and write it to file."""
  80. # We have to construct the ModelManifest lazily, because importing
  81. # sentry.testutils.modelmanifest too early causes a dependency cycle.
  82. from sentry.testutils.modelmanifest import ModelManifest
  83. if _MODEL_MANIFEST_FILE_PATH:
  84. global _model_manifest
  85. _model_manifest = ModelManifest.open(_MODEL_MANIFEST_FILE_PATH)
  86. with _model_manifest.write():
  87. yield
  88. else:
  89. yield
  90. @pytest.fixture(scope="class", autouse=True)
  91. def register_class_in_model_manifest(request: pytest.FixtureRequest):
  92. if _model_manifest:
  93. with _model_manifest.register(request.node.nodeid):
  94. yield
  95. else:
  96. yield
  97. @pytest.fixture(autouse=True)
  98. def validate_silo_mode():
  99. # NOTE! Hybrid cloud uses many mechanisms to simulate multiple different configurations of the application
  100. # during tests. It depends upon `override_settings` using the correct contextmanager behaviors and correct
  101. # thread handling in acceptance tests. If you hit one of these, it's possible either that cleanup logic has
  102. # a bug, or you may be using a contextmanager incorrectly. Let us know and we can help!
  103. if SiloMode.get_current_mode() != SiloMode.MONOLITH:
  104. raise Exception(
  105. "Possible test leak bug! SiloMode was not reset to Monolith between tests. Please read the comment for validate_silo_mode() in tests/conftest.py."
  106. )
  107. yield
  108. if SiloMode.get_current_mode() != SiloMode.MONOLITH:
  109. raise Exception(
  110. "Possible test leak bug! SiloMode was not reset to Monolith between tests. Please read the comment for validate_silo_mode() in tests/conftest.py."
  111. )
  112. @pytest.fixture(autouse=True)
  113. def setup_simulate_on_commit(request):
  114. from sentry.testutils.hybrid_cloud import simulate_on_commit
  115. with simulate_on_commit(request):
  116. yield
  117. @pytest.fixture(autouse=True)
  118. def protect_hybrid_cloud_writes_and_deletes(request):
  119. """
  120. Ensure the deletions on any hybrid cloud foreign keys would be recorded to an outbox
  121. by preventing any deletes that do not pass through a special 'connection'.
  122. This logic creates an additional database role which cannot make deletions on special
  123. restricted hybrid cloud objects, forcing code that would delete it in tests to explicitly
  124. escalate their role -- the hope being that only codepaths that are smart about outbox
  125. creation will do so.
  126. If you are running into issues with permissions to delete objects, consider whether
  127. you are deleting an object with a hybrid cloud foreign key pointing to it, and whether
  128. there is an 'expected' way to delete it (usually through the ORM .delete() method, but
  129. not the QuerySet.delete() or raw SQL delete).
  130. If you are certain you need to delete the objects in a new codepath, check out User.delete
  131. logic to see how to escalate the connection's role in tests. Make absolutely sure that you
  132. create Outbox objects in the same transaction that matches what you delete.
  133. See sentry.testutils.silo for where the postgres_unprivileged role comes from and
  134. how its permissions are assigned.
  135. """
  136. for conn in connections.all():
  137. try:
  138. with conn.cursor() as cursor:
  139. cursor.execute("SET ROLE 'postgres'")
  140. except (RuntimeError, AssertionError) as e:
  141. # Tests that do not have access to the database should pass through.
  142. # Ideally we'd use request.fixture names to infer this, but there didn't seem to be a single stable
  143. # fixture name that fully covered all cases of database access, so this approach is "try and then fail".
  144. if "Database access not allowed" in str(e) or "Database queries to" in str(e):
  145. yield
  146. return
  147. raise e
  148. with conn.cursor() as cursor:
  149. cursor.execute("SET ROLE 'postgres_unprivileged'")
  150. try:
  151. yield
  152. finally:
  153. for connection in connections.all():
  154. with connection.cursor() as cursor:
  155. cursor.execute("SET ROLE 'postgres'")
  156. @pytest.fixture(autouse=True)
  157. def audit_hybrid_cloud_writes_and_deletes(request):
  158. """
  159. Ensure that write operations on hybrid cloud foreign keys are recorded
  160. alongside outboxes or use a context manager to indicate that the
  161. caller has considered outbox and didn't accidentally forget.
  162. Generally you can avoid assertion errors from these checks by:
  163. 1. Running deletion/write logic within an `outbox_context`.
  164. 2. Using Model.delete()/save methods that create outbox messages in the
  165. same transaction as a delete operation.
  166. Scenarios that are generally always unsafe are using
  167. `QuerySet.delete()`, `QuerySet.update()` or raw SQL to perform
  168. writes.
  169. The User.delete() method is a good example of how to safely
  170. delete records and generate outbox messages.
  171. """
  172. from sentry.testutils.silo import validate_protected_queries
  173. debug_cursor_state: MutableMapping[str, bool] = {}
  174. for conn in connections.all():
  175. debug_cursor_state[conn.alias] = conn.force_debug_cursor
  176. conn.queries_log.clear()
  177. conn.force_debug_cursor = True
  178. try:
  179. yield
  180. finally:
  181. for conn in connections.all():
  182. conn.force_debug_cursor = debug_cursor_state[conn.alias]
  183. validate_protected_queries(conn.queries)