test_settings_electricity_price.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. """Integration tests for #1356 — API keys writing electricity price.
  2. The contract these tests pin:
  3. ``POST /settings/electricity-price`` is the *only* settings field writable
  4. via API key, gated by an opt-in ``can_update_energy_cost`` scope. Full
  5. ``PATCH /settings`` remains denied for API keys because it can rewrite
  6. SMTP/LDAP/MQTT credentials. Two independent fences must pass:
  7. 1. Caller is a JWT user with SETTINGS_UPDATE permission, OR
  8. 2. Caller is an API key with ``can_update_energy_cost = True``.
  9. Tests also confirm: (a) API keys without the flag get 403 with a
  10. recognizable error, (b) the deny-list for ``PATCH /settings`` still fires
  11. for keys that flipped only ``can_update_energy_cost`` on, so flipping the
  12. narrow flag doesn't accidentally widen settings-write capability.
  13. """
  14. import pytest
  15. from httpx import AsyncClient
  16. from sqlalchemy import select
  17. from sqlalchemy.ext.asyncio import AsyncSession
  18. from backend.app.core.auth import generate_api_key
  19. from backend.app.models.api_key import APIKey
  20. from backend.app.models.settings import Settings
  21. from backend.app.models.user import User
  22. async def _setup_auth_with_admin(client: AsyncClient) -> str:
  23. """Enable auth + return an admin bearer token. Same pattern as #1182 tests."""
  24. await client.post(
  25. "/api/v1/auth/setup",
  26. json={
  27. "auth_enabled": True,
  28. "admin_username": "energyadmin",
  29. "admin_password": "AdminPass1!", # pragma: allowlist secret
  30. },
  31. )
  32. login = await client.post(
  33. "/api/v1/auth/login",
  34. json={"username": "energyadmin", "password": "AdminPass1!"}, # pragma: allowlist secret
  35. )
  36. return login.json()["access_token"]
  37. async def _make_api_key(
  38. db: AsyncSession,
  39. *,
  40. owner_id: int | None,
  41. can_update_energy_cost: bool,
  42. ) -> str:
  43. full_key, key_hash, key_prefix = generate_api_key()
  44. api_key = APIKey(
  45. name="energy-tariff",
  46. key_hash=key_hash,
  47. key_prefix=key_prefix,
  48. user_id=owner_id,
  49. can_update_energy_cost=can_update_energy_cost,
  50. )
  51. db.add(api_key)
  52. await db.commit()
  53. return full_key
  54. async def _read_setting(db: AsyncSession, key: str) -> str | None:
  55. result = await db.execute(select(Settings).where(Settings.key == key))
  56. row = result.scalar_one_or_none()
  57. return row.value if row else None
  58. class TestCreateAPIKeyWithEnergyScope:
  59. @pytest.mark.asyncio
  60. @pytest.mark.integration
  61. async def test_create_stamps_energy_flag(self, async_client: AsyncClient):
  62. token = await _setup_auth_with_admin(async_client)
  63. resp = await async_client.post(
  64. "/api/v1/api-keys/",
  65. headers={"Authorization": f"Bearer {token}"},
  66. json={"name": "tariff-push", "can_update_energy_cost": True},
  67. )
  68. assert resp.status_code == 200
  69. body = resp.json()
  70. assert body["can_update_energy_cost"] is True
  71. @pytest.mark.asyncio
  72. @pytest.mark.integration
  73. async def test_create_without_flag_defaults_off(self, async_client: AsyncClient):
  74. token = await _setup_auth_with_admin(async_client)
  75. resp = await async_client.post(
  76. "/api/v1/api-keys/",
  77. headers={"Authorization": f"Bearer {token}"},
  78. json={"name": "no-energy"},
  79. )
  80. assert resp.status_code == 200
  81. assert resp.json()["can_update_energy_cost"] is False
  82. class TestElectricityPriceEndpoint:
  83. @pytest.mark.asyncio
  84. @pytest.mark.integration
  85. async def test_api_key_with_flag_updates_price(self, async_client: AsyncClient, db_session: AsyncSession):
  86. """Happy path: API key with ``can_update_energy_cost=True`` POSTs a new
  87. price and the setting persists."""
  88. await _setup_auth_with_admin(async_client)
  89. result = await db_session.execute(select(User).where(User.username == "energyadmin"))
  90. admin = result.scalar_one()
  91. full_key = await _make_api_key(db_session, owner_id=admin.id, can_update_energy_cost=True)
  92. resp = await async_client.post(
  93. "/api/v1/settings/electricity-price",
  94. headers={"X-API-Key": full_key},
  95. json={"energy_cost_per_kwh": 0.42},
  96. )
  97. assert resp.status_code == 200, resp.json()
  98. # The route returns the full settings response — confirm the new value
  99. # is reflected (the rest of the body is the standard scrubbed response).
  100. assert resp.json()["energy_cost_per_kwh"] == 0.42
  101. # Persisted in the settings table.
  102. db_session.expire_all()
  103. assert await _read_setting(db_session, "energy_cost_per_kwh") == "0.42"
  104. @pytest.mark.asyncio
  105. @pytest.mark.integration
  106. async def test_api_key_without_flag_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
  107. """Default API key (can_update_energy_cost=False) → 403."""
  108. await _setup_auth_with_admin(async_client)
  109. result = await db_session.execute(select(User).where(User.username == "energyadmin"))
  110. admin = result.scalar_one()
  111. full_key = await _make_api_key(db_session, owner_id=admin.id, can_update_energy_cost=False)
  112. resp = await async_client.post(
  113. "/api/v1/settings/electricity-price",
  114. headers={"X-API-Key": full_key},
  115. json={"energy_cost_per_kwh": 0.42},
  116. )
  117. assert resp.status_code == 403
  118. # Don't pin the exact detail string — just that it identifies the
  119. # missing permission. Keeps the test from being noise on copy tweaks.
  120. assert "energy" in resp.json()["detail"].lower()
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_admin_user_with_settings_update_allowed(self, async_client: AsyncClient, db_session: AsyncSession):
  124. """JWT user with SETTINGS_UPDATE permission can still hit this route."""
  125. token = await _setup_auth_with_admin(async_client)
  126. resp = await async_client.post(
  127. "/api/v1/settings/electricity-price",
  128. headers={"Authorization": f"Bearer {token}"},
  129. json={"energy_cost_per_kwh": 0.19},
  130. )
  131. assert resp.status_code == 200
  132. assert resp.json()["energy_cost_per_kwh"] == 0.19
  133. @pytest.mark.asyncio
  134. @pytest.mark.integration
  135. async def test_unauthenticated_rejected(self, async_client: AsyncClient):
  136. """No credentials when auth is enabled → 401."""
  137. await _setup_auth_with_admin(async_client)
  138. resp = await async_client.post(
  139. "/api/v1/settings/electricity-price",
  140. json={"energy_cost_per_kwh": 0.19},
  141. )
  142. assert resp.status_code == 401
  143. @pytest.mark.asyncio
  144. @pytest.mark.integration
  145. async def test_negative_price_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
  146. """The Pydantic ``ge=0`` constraint catches obviously-wrong values
  147. before they reach the settings table — a negative tariff is never
  148. valid in any real market."""
  149. await _setup_auth_with_admin(async_client)
  150. result = await db_session.execute(select(User).where(User.username == "energyadmin"))
  151. admin = result.scalar_one()
  152. full_key = await _make_api_key(db_session, owner_id=admin.id, can_update_energy_cost=True)
  153. resp = await async_client.post(
  154. "/api/v1/settings/electricity-price",
  155. headers={"X-API-Key": full_key},
  156. json={"energy_cost_per_kwh": -0.05},
  157. )
  158. assert resp.status_code == 422 # FastAPI validation
  159. class TestPatchSettingsStillBlocked:
  160. """Regression guard: flipping the narrow energy-cost flag must NOT widen
  161. full ``PATCH /settings`` access. The general settings-update deny for
  162. API keys (which protects SMTP/LDAP/MQTT credentials) stays in place."""
  163. @pytest.mark.asyncio
  164. @pytest.mark.integration
  165. async def test_patch_settings_still_denied_with_energy_flag(
  166. self, async_client: AsyncClient, db_session: AsyncSession
  167. ):
  168. await _setup_auth_with_admin(async_client)
  169. result = await db_session.execute(select(User).where(User.username == "energyadmin"))
  170. admin = result.scalar_one()
  171. full_key = await _make_api_key(db_session, owner_id=admin.id, can_update_energy_cost=True)
  172. resp = await async_client.patch(
  173. "/api/v1/settings/",
  174. headers={"X-API-Key": full_key},
  175. json={"energy_cost_per_kwh": 0.99},
  176. )
  177. # Still denied — the wider route uses the deny-list path.
  178. assert resp.status_code == 403
  179. assert "administrative" in resp.json()["detail"].lower()