test_my_models_view.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. """Integration test for the non_admin_user_models view's principal logic.
  2. This test exercises the actual SQL view against a real Postgres database. It
  3. is opt-in via the GPUSTACK_PG_TEST_URL env var:
  4. docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:15
  5. GPUSTACK_PG_TEST_URL=postgresql://postgres:postgres@localhost:5432 \
  6. pytest tests/api/test_my_models_view.py -v
  7. Without that env var, the test is skipped.
  8. """
  9. import os
  10. import secrets
  11. import subprocess
  12. import sys
  13. import pytest
  14. from sqlalchemy import create_engine, text
  15. PG_BASE_URL = os.environ.get("GPUSTACK_PG_TEST_URL")
  16. pytestmark = pytest.mark.skipif(
  17. not PG_BASE_URL,
  18. reason="GPUSTACK_PG_TEST_URL not set; skipping real-DB view test",
  19. )
  20. @pytest.fixture
  21. def fresh_db():
  22. """Create a unique DB, run migrations, hand back a sync engine."""
  23. db_name = f"gpustack_p4_view_{secrets.token_hex(4)}"
  24. admin = create_engine(f"{PG_BASE_URL}/postgres", isolation_level="AUTOCOMMIT")
  25. with admin.connect() as conn:
  26. conn.execute(text(f'CREATE DATABASE "{db_name}"'))
  27. admin.dispose()
  28. db_url = f"{PG_BASE_URL}/{db_name}"
  29. repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
  30. env = {**os.environ, "DATABASE_URL": db_url}
  31. subprocess.run(
  32. [sys.executable, "-m", "alembic", "upgrade", "head"],
  33. cwd=repo_root,
  34. env=env,
  35. check=True,
  36. capture_output=True,
  37. )
  38. engine = create_engine(db_url)
  39. try:
  40. yield engine
  41. finally:
  42. engine.dispose()
  43. admin = create_engine(f"{PG_BASE_URL}/postgres", isolation_level="AUTOCOMMIT")
  44. with admin.connect() as conn:
  45. conn.execute(
  46. text(
  47. "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
  48. "WHERE datname = :db AND pid <> pg_backend_pid()"
  49. ),
  50. {"db": db_name},
  51. )
  52. conn.execute(text(f'DROP DATABASE IF EXISTS "{db_name}"'))
  53. admin.dispose()
  54. def _seed(engine):
  55. """Seed minimal data for the visibility matrix.
  56. Returns ids: (alice, bob, carol, org1=platform, org2, group_alice_bob,
  57. route_public, route_authed, route_user, route_org, route_group).
  58. alice: in platform Org via membership; member of group_alice_bob.
  59. bob: in platform Org via membership; member of group_alice_bob.
  60. carol: in org2 only.
  61. """
  62. with engine.begin() as conn:
  63. conn.execute(
  64. text(
  65. """
  66. INSERT INTO users (username, hashed_password, is_admin,
  67. is_active, is_system, source, require_password_change,
  68. created_at, updated_at)
  69. VALUES
  70. ('alice','x',false,true,false,'Local',false,NOW(),NOW()),
  71. ('bob','x',false,true,false,'Local',false,NOW(),NOW()),
  72. ('carol','x',false,true,false,'Local',false,NOW(),NOW())
  73. """
  74. )
  75. )
  76. ids = conn.execute(
  77. text(
  78. "SELECT id, username FROM users "
  79. "WHERE username IN ('alice','bob','carol') ORDER BY id"
  80. )
  81. ).fetchall()
  82. alice = next(r.id for r in ids if r.username == "alice")
  83. bob = next(r.id for r in ids if r.username == "bob")
  84. carol = next(r.id for r in ids if r.username == "carol")
  85. # Org 1 (platform) was seeded by the foundation migration. Add Org 2.
  86. conn.execute(
  87. text(
  88. "INSERT INTO organizations (id, name, slug, "
  89. "created_at, updated_at) VALUES "
  90. "(2, 'Acme', 'acme', NOW(), NOW())"
  91. )
  92. )
  93. conn.execute(
  94. text(
  95. """
  96. INSERT INTO organization_memberships
  97. (user_id, organization_id, role, created_at)
  98. VALUES
  99. (:a, 1, 'MEMBER', NOW()),
  100. (:b, 1, 'MEMBER', NOW()),
  101. (:c, 2, 'MEMBER', NOW())
  102. """
  103. ),
  104. {"a": alice, "b": bob, "c": carol},
  105. )
  106. # Group in platform Org with alice + bob.
  107. group_id = conn.execute(
  108. text(
  109. "INSERT INTO user_groups (organization_id, name, created_at, "
  110. "updated_at) VALUES (1, 'team-a', NOW(), NOW()) RETURNING id"
  111. )
  112. ).scalar()
  113. conn.execute(
  114. text(
  115. """
  116. INSERT INTO user_group_memberships (user_id, group_id, created_at)
  117. VALUES (:a, :g, NOW()), (:b, :g, NOW())
  118. """
  119. ),
  120. {"a": alice, "b": bob, "g": group_id},
  121. )
  122. # Five routes covering every visibility branch. Each lives in
  123. # platform Org, owner=org/1.
  124. def _ins_route(name, policy):
  125. return conn.execute(
  126. text(
  127. f"""
  128. INSERT INTO model_routes
  129. (name, access_policy, organization_id, created_by_model,
  130. targets, ready_targets, generic_proxy,
  131. categories, meta,
  132. created_at, updated_at)
  133. VALUES (:n, '{policy}', 1, false, 0, 0, false,
  134. '[]'::jsonb, '{{}}'::jsonb, NOW(), NOW())
  135. RETURNING id
  136. """
  137. ),
  138. {"n": name},
  139. ).scalar()
  140. r_public = _ins_route("r-public", "PUBLIC")
  141. r_authed = _ins_route("r-authed", "AUTHED")
  142. r_user = _ins_route("r-user", "ALLOWED_PRINCIPALS")
  143. r_org = _ins_route("r-org", "ALLOWED_PRINCIPALS")
  144. r_group = _ins_route("r-group", "ALLOWED_PRINCIPALS")
  145. # Principals
  146. conn.execute(
  147. text(
  148. "INSERT INTO model_route_principals (route_id, principal_type, principal_id) "
  149. "VALUES (:r, 'USER', :u)"
  150. ),
  151. {"r": r_user, "u": alice},
  152. )
  153. conn.execute(
  154. text(
  155. "INSERT INTO model_route_principals (route_id, principal_type, principal_id) "
  156. "VALUES (:r, 'ORG', 2)"
  157. ),
  158. {"r": r_org}, # granted to Org Acme (carol)
  159. )
  160. conn.execute(
  161. text(
  162. "INSERT INTO model_route_principals (route_id, principal_type, principal_id) "
  163. "VALUES (:r, 'GROUP', :g)"
  164. ),
  165. {"r": r_group, "g": group_id}, # granted to group team-a (alice + bob)
  166. )
  167. return {
  168. "alice": alice,
  169. "bob": bob,
  170. "carol": carol,
  171. "r_public": r_public,
  172. "r_authed": r_authed,
  173. "r_user": r_user,
  174. "r_org": r_org,
  175. "r_group": r_group,
  176. }
  177. def _visible_route_ids(engine, user_id: int):
  178. with engine.connect() as conn:
  179. rows = conn.execute(
  180. text("SELECT id FROM non_admin_user_models WHERE user_id = :u ORDER BY id"),
  181. {"u": user_id},
  182. ).fetchall()
  183. return {r.id for r in rows}
  184. def test_view_visibility_matrix(fresh_db):
  185. ids = _seed(fresh_db)
  186. alice_visible = _visible_route_ids(fresh_db, ids["alice"])
  187. # alice: PUBLIC + AUTHED + USER (her) + GROUP (team-a). NOT r_org (Acme).
  188. assert alice_visible == {
  189. ids["r_public"],
  190. ids["r_authed"],
  191. ids["r_user"],
  192. ids["r_group"],
  193. }
  194. bob_visible = _visible_route_ids(fresh_db, ids["bob"])
  195. # bob: PUBLIC + AUTHED + GROUP (team-a). NOT r_user (alice only).
  196. assert bob_visible == {
  197. ids["r_public"],
  198. ids["r_authed"],
  199. ids["r_group"],
  200. }
  201. carol_visible = _visible_route_ids(fresh_db, ids["carol"])
  202. # carol: PUBLIC + AUTHED + ORG (Acme). NOT r_user, NOT r_group.
  203. assert carol_visible == {
  204. ids["r_public"],
  205. ids["r_authed"],
  206. ids["r_org"],
  207. }