conftest.py 7.7 KB

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