test_settings_and_plug_secrets.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. """Settings and smart plug secrets are write-only.
  2. Responses show a set secret as a fixed placeholder. Saving the placeholder back
  3. keeps the stored value, an empty value clears it, and a stored secret only goes
  4. to the destination it was entered for: changing the destination needs the
  5. secret entered again. Connection tests fill in a stored secret for those who
  6. may edit it, for its own destination only.
  7. """
  8. from __future__ import annotations
  9. from unittest.mock import AsyncMock, patch
  10. import pytest
  11. from httpx import AsyncClient
  12. from sqlalchemy import select
  13. from backend.app.models.settings import Settings
  14. from backend.app.models.smart_plug import SmartPlug
  15. from backend.app.services.secret_fields import SECRET_PLACEHOLDER
  16. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  17. M = SECRET_PLACEHOLDER
  18. SETTINGS_SECRETS = {
  19. "mqtt_password": "mqtt-pw",
  20. "ha_token": "ha-tok",
  21. "prometheus_token": "prom-tok",
  22. "obico_ml_token": "obico-tok",
  23. }
  24. async def _admin(client: AsyncClient) -> dict[str, str]:
  25. await client.post(
  26. "/api/v1/auth/setup",
  27. json={"auth_enabled": True, "admin_username": "ssadmin", "admin_password": "AdminPass1!"},
  28. )
  29. login = await client.post("/api/v1/auth/login", json={"username": "ssadmin", "password": "AdminPass1!"})
  30. assert login.status_code == 200, login.text
  31. return {"Authorization": f"Bearer {login.json()['access_token']}"}
  32. async def _user(client: AsyncClient, admin: dict[str, str], name: str, permissions: list[str]) -> dict[str, str]:
  33. group = await client.post("/api/v1/groups/", headers=admin, json={"name": f"g_{name}", "permissions": permissions})
  34. assert group.status_code in (200, 201), group.text
  35. created = await client.post(
  36. "/api/v1/users/",
  37. headers=admin,
  38. json={"username": name, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
  39. )
  40. assert created.status_code in (200, 201), created.text
  41. login = await client.post("/api/v1/auth/login", json={"username": name, "password": "UserPass1!"})
  42. return {"Authorization": f"Bearer {login.json()['access_token']}"}
  43. async def _stored_setting(db_session, key: str) -> str | None:
  44. db_session.expire_all()
  45. row = await db_session.scalar(select(Settings).where(Settings.key == key))
  46. return row.value if row else None
  47. async def _seed_settings(client: AsyncClient, admin: dict[str, str]) -> None:
  48. with patch("backend.app.services.mqtt_relay.mqtt_relay.configure", new_callable=AsyncMock):
  49. response = await client.put(
  50. "/api/v1/settings/",
  51. headers=admin,
  52. json={
  53. **SETTINGS_SECRETS,
  54. "mqtt_broker": "broker.example",
  55. "mqtt_port": 1883,
  56. "mqtt_use_tls": False,
  57. "ha_url": "http://ha.example:8123",
  58. "obico_ml_url": "http://obico.example:3333",
  59. },
  60. )
  61. assert response.status_code == 200, response.text
  62. async def _put_settings(client: AsyncClient, headers: dict[str, str], body: dict):
  63. with patch("backend.app.services.mqtt_relay.mqtt_relay.configure", new_callable=AsyncMock):
  64. return await client.put("/api/v1/settings/", headers=headers, json=body)
  65. # --- settings ---
  66. async def test_a_settings_reader_gets_placeholders(async_client: AsyncClient):
  67. admin = await _admin(async_client)
  68. await _seed_settings(async_client, admin)
  69. viewer = await _user(async_client, admin, "sviewer", ["settings:read"])
  70. shown = (await async_client.get("/api/v1/settings/", headers=viewer)).json()
  71. saved_response = (await _put_settings(async_client, admin, {"date_format": "iso"})).json()
  72. for key in SETTINGS_SECRETS:
  73. assert shown[key] == M, key
  74. assert saved_response[key] == M, key
  75. async def test_an_unset_settings_secret_is_shown_empty(async_client: AsyncClient):
  76. admin = await _admin(async_client)
  77. shown = (await async_client.get("/api/v1/settings/", headers=admin)).json()
  78. assert shown["mqtt_password"] == ""
  79. async def test_settings_saved_back_as_shown_keep_their_secrets(async_client: AsyncClient, db_session):
  80. admin = await _admin(async_client)
  81. await _seed_settings(async_client, admin)
  82. shown = (await async_client.get("/api/v1/settings/", headers=admin)).json()
  83. response = await _put_settings(async_client, admin, shown)
  84. assert response.status_code == 200, response.text
  85. for key, value in SETTINGS_SECRETS.items():
  86. assert await _stored_setting(db_session, key) == value, key
  87. async def test_an_empty_settings_secret_clears_it(async_client: AsyncClient, db_session):
  88. admin = await _admin(async_client)
  89. await _seed_settings(async_client, admin)
  90. await _put_settings(async_client, admin, {"prometheus_token": ""})
  91. assert await _stored_setting(db_session, "prometheus_token") == ""
  92. @pytest.mark.parametrize(
  93. ("secret", "change"),
  94. [
  95. ("mqtt_password", {"mqtt_broker": "broker.elsewhere.example"}),
  96. ("mqtt_password", {"mqtt_port": 8883}),
  97. ("mqtt_password", {"mqtt_use_tls": True}),
  98. ("ha_token", {"ha_url": "http://ha.elsewhere.example:8123"}),
  99. ("obico_ml_token", {"obico_ml_url": "http://obico.elsewhere.example:3333"}),
  100. ],
  101. )
  102. @pytest.mark.parametrize("secret_sent", [True, False])
  103. async def test_a_kept_settings_secret_does_not_follow_a_new_destination(
  104. async_client: AsyncClient, db_session, secret, change, secret_sent
  105. ):
  106. """Kept means sent as the placeholder, or not sent at all."""
  107. admin = await _admin(async_client)
  108. await _seed_settings(async_client, admin)
  109. body = {**change, **({secret: M} if secret_sent else {})}
  110. response = await _put_settings(async_client, admin, body)
  111. assert response.status_code == 400, response.text
  112. field = next(iter(change))
  113. shown_before = {"mqtt_broker": "broker.example", "mqtt_port": "1883", "mqtt_use_tls": "false"}
  114. assert (
  115. await _stored_setting(db_session, field)
  116. == {
  117. **shown_before,
  118. "ha_url": "http://ha.example:8123",
  119. "obico_ml_url": "http://obico.example:3333",
  120. }[field]
  121. )
  122. async def test_a_new_settings_destination_with_its_secret_is_saved(async_client: AsyncClient, db_session):
  123. admin = await _admin(async_client)
  124. await _seed_settings(async_client, admin)
  125. response = await _put_settings(
  126. async_client, admin, {"ha_url": "http://ha.new.example:8123", "ha_token": "new-ha-tok"}
  127. )
  128. assert response.status_code == 200, response.text
  129. assert await _stored_setting(db_session, "ha_token") == "new-ha-tok"
  130. async def test_the_placeholder_cannot_stand_for_a_settings_secret_never_set(async_client: AsyncClient, db_session):
  131. admin = await _admin(async_client)
  132. response = await _put_settings(async_client, admin, {"prometheus_token": M})
  133. assert response.status_code == 400, response.text
  134. assert not await _stored_setting(db_session, "prometheus_token")
  135. async def test_an_api_key_reader_still_gets_secrets_blank(async_client: AsyncClient, db_session):
  136. admin = await _admin(async_client)
  137. await _seed_settings(async_client, admin)
  138. key = await async_client.post("/api/v1/api-keys/", headers=admin, json={"name": "reader", "can_read_status": True})
  139. assert key.status_code in (200, 201), key.text
  140. api_key = key.json()["key"]
  141. shown = await async_client.get("/api/v1/settings/", headers={"X-API-Key": api_key})
  142. assert shown.status_code == 200, shown.text
  143. for name in SETTINGS_SECRETS:
  144. assert shown.json()[name] in ("", M), name
  145. # --- Home Assistant connection test ---
  146. async def _ha_test(client: AsyncClient, headers: dict[str, str], body: dict):
  147. with patch(
  148. "backend.app.api.routes.smart_plugs.homeassistant_service.test_connection",
  149. new_callable=AsyncMock,
  150. return_value={"success": True, "message": "ok", "error": None},
  151. ) as test:
  152. response = await client.post("/api/v1/smart-plugs/ha/test-connection", headers=headers, json=body)
  153. return response, test
  154. async def test_the_ha_test_uses_the_stored_token_for_the_stored_url(async_client: AsyncClient):
  155. admin = await _admin(async_client)
  156. await _seed_settings(async_client, admin)
  157. response, test = await _ha_test(async_client, admin, {"url": "http://ha.example:8123", "token": M})
  158. assert response.status_code == 200, response.text
  159. test.assert_awaited_once_with("http://ha.example:8123", "ha-tok")
  160. async def test_the_ha_test_does_not_send_the_stored_token_elsewhere(async_client: AsyncClient):
  161. admin = await _admin(async_client)
  162. await _seed_settings(async_client, admin)
  163. response, test = await _ha_test(async_client, admin, {"url": "http://ha.elsewhere.example:8123", "token": M})
  164. assert response.status_code == 400, response.text
  165. test.assert_not_awaited()
  166. async def test_the_ha_test_with_the_stored_token_needs_settings_update(async_client: AsyncClient):
  167. admin = await _admin(async_client)
  168. await _seed_settings(async_client, admin)
  169. controller = await _user(async_client, admin, "plugctl", ["smart_plugs:control", "settings:read"])
  170. response, test = await _ha_test(async_client, controller, {"url": "http://ha.example:8123", "token": M})
  171. assert response.status_code == 403, response.text
  172. test.assert_not_awaited()
  173. # --- smart plugs ---
  174. TASMOTA = {"name": "T", "plug_type": "tasmota", "ip_address": "10.0.0.5", "username": "admin", "password": "tas-pw"}
  175. REST = {
  176. "name": "R",
  177. "plug_type": "rest",
  178. "rest_on_url": "http://plug.example/on",
  179. "rest_off_url": "http://plug.example/off",
  180. "rest_headers": '{"Authorization": "Bearer rest-tok"}',
  181. }
  182. async def _create_plug(client: AsyncClient, headers: dict[str, str], body: dict) -> dict:
  183. response = await client.post("/api/v1/smart-plugs/", headers=headers, json=body)
  184. assert response.status_code == 200, response.text
  185. return response.json()
  186. async def _stored_plug(db_session, plug_id: int) -> SmartPlug:
  187. db_session.expire_all()
  188. return await db_session.get(SmartPlug, plug_id)
  189. async def test_a_plug_reader_gets_placeholders(async_client: AsyncClient):
  190. admin = await _admin(async_client)
  191. viewer = await _user(async_client, admin, "pviewer", ["smart_plugs:read"])
  192. tasmota = await _create_plug(async_client, admin, TASMOTA)
  193. rest = await _create_plug(async_client, admin, REST)
  194. listed = {p["id"]: p for p in (await async_client.get("/api/v1/smart-plugs/", headers=viewer)).json()}
  195. single = (await async_client.get(f"/api/v1/smart-plugs/{tasmota['id']}", headers=viewer)).json()
  196. assert tasmota["password"] == M and rest["rest_headers"] == M
  197. assert listed[tasmota["id"]]["password"] == M
  198. assert listed[rest["id"]]["rest_headers"] == M
  199. assert single["password"] == M
  200. async def test_a_plug_saved_back_as_shown_keeps_its_secrets(async_client: AsyncClient, db_session):
  201. admin = await _admin(async_client)
  202. tasmota = await _create_plug(async_client, admin, TASMOTA)
  203. rest = await _create_plug(async_client, admin, REST)
  204. for plug in (tasmota, rest):
  205. body = {
  206. k: plug[k]
  207. for k in (
  208. "name",
  209. "plug_type",
  210. "ip_address",
  211. "username",
  212. "password",
  213. "rest_on_url",
  214. "rest_off_url",
  215. "rest_headers",
  216. )
  217. }
  218. response = await async_client.patch(f"/api/v1/smart-plugs/{plug['id']}", headers=admin, json=body)
  219. assert response.status_code == 200, response.text
  220. assert (await _stored_plug(db_session, tasmota["id"])).password == "tas-pw"
  221. assert (await _stored_plug(db_session, rest["id"])).rest_headers == REST["rest_headers"]
  222. @pytest.mark.parametrize(
  223. ("plug", "change"),
  224. [
  225. (TASMOTA, {"ip_address": "10.0.0.66"}),
  226. (REST, {"rest_on_url": "http://elsewhere.example/on"}),
  227. (REST, {"rest_status_url": "http://elsewhere.example/status"}),
  228. (TASMOTA, {"plug_type": "rest", "rest_on_url": "http://elsewhere.example/on"}),
  229. ],
  230. )
  231. @pytest.mark.parametrize("secret_sent", [True, False])
  232. async def test_a_kept_plug_secret_does_not_follow_a_new_destination(
  233. async_client: AsyncClient, db_session, plug, change, secret_sent
  234. ):
  235. admin = await _admin(async_client)
  236. created = await _create_plug(async_client, admin, plug)
  237. secret = "password" if plug is TASMOTA else "rest_headers"
  238. body = {**change, **({secret: M} if secret_sent else {})}
  239. response = await async_client.patch(f"/api/v1/smart-plugs/{created['id']}", headers=admin, json=body)
  240. assert response.status_code == 400, response.text
  241. stored = await _stored_plug(db_session, created["id"])
  242. assert getattr(stored, secret) == plug[secret]
  243. assert stored.plug_type == plug["plug_type"]
  244. async def test_a_new_plug_destination_with_its_secret_is_saved(async_client: AsyncClient, db_session):
  245. admin = await _admin(async_client)
  246. created = await _create_plug(async_client, admin, TASMOTA)
  247. response = await async_client.patch(
  248. f"/api/v1/smart-plugs/{created['id']}", headers=admin, json={"ip_address": "10.0.0.7", "password": "new-pw"}
  249. )
  250. assert response.status_code == 200, response.text
  251. assert (await _stored_plug(db_session, created["id"])).password == "new-pw"
  252. async def test_a_new_plug_cannot_take_the_placeholder_as_its_secret(async_client: AsyncClient):
  253. admin = await _admin(async_client)
  254. response = await async_client.post("/api/v1/smart-plugs/", headers=admin, json={**TASMOTA, "password": M})
  255. assert response.status_code == 400, response.text
  256. async def test_the_tasmota_test_uses_the_stored_password_for_its_own_ip(async_client: AsyncClient):
  257. admin = await _admin(async_client)
  258. created = await _create_plug(async_client, admin, TASMOTA)
  259. with patch(
  260. "backend.app.api.routes.smart_plugs.tasmota_service.test_connection",
  261. new_callable=AsyncMock,
  262. return_value={"success": True, "state": "ON"},
  263. ) as test:
  264. same = await async_client.post(
  265. "/api/v1/smart-plugs/test-connection",
  266. headers=admin,
  267. json={"ip_address": "10.0.0.5", "username": "admin", "password": M, "plug_id": created["id"]},
  268. )
  269. moved = await async_client.post(
  270. "/api/v1/smart-plugs/test-connection",
  271. headers=admin,
  272. json={"ip_address": "10.0.0.66", "username": "admin", "password": M, "plug_id": created["id"]},
  273. )
  274. assert same.status_code == 200, same.text
  275. test.assert_awaited_once_with("10.0.0.5", "admin", "tas-pw")
  276. assert moved.status_code == 400, moved.text
  277. async def test_a_plug_test_with_a_stored_secret_needs_plug_update(async_client: AsyncClient):
  278. admin = await _admin(async_client)
  279. controller = await _user(async_client, admin, "pctl", ["smart_plugs:read", "smart_plugs:control"])
  280. created = await _create_plug(async_client, admin, REST)
  281. with patch(
  282. "backend.app.api.routes.smart_plugs.rest_smart_plug_service.test_connection",
  283. new_callable=AsyncMock,
  284. return_value={"success": True},
  285. ) as test:
  286. response = await async_client.post(
  287. "/api/v1/smart-plugs/rest/test-connection",
  288. headers=controller,
  289. json={"url": "http://plug.example/on", "method": "POST", "headers": M, "plug_id": created["id"]},
  290. )
  291. assert response.status_code == 403, response.text
  292. test.assert_not_awaited()
  293. async def test_the_rest_test_uses_the_stored_headers_for_its_own_urls_only(async_client: AsyncClient):
  294. admin = await _admin(async_client)
  295. created = await _create_plug(async_client, admin, REST)
  296. with patch(
  297. "backend.app.api.routes.smart_plugs.rest_smart_plug_service.test_connection",
  298. new_callable=AsyncMock,
  299. return_value={"success": True},
  300. ) as test:
  301. same = await async_client.post(
  302. "/api/v1/smart-plugs/rest/test-connection",
  303. headers=admin,
  304. json={"url": "http://plug.example/off", "method": "POST", "headers": M, "plug_id": created["id"]},
  305. )
  306. moved = await async_client.post(
  307. "/api/v1/smart-plugs/rest/test-connection",
  308. headers=admin,
  309. json={"url": "http://elsewhere.example/x", "method": "POST", "headers": M, "plug_id": created["id"]},
  310. )
  311. assert same.status_code == 200, same.text
  312. test.assert_awaited_once_with("http://plug.example/off", "POST", REST["rest_headers"])
  313. assert moved.status_code == 400, moved.text
  314. async def test_a_test_without_a_plug_does_not_send_the_placeholder(async_client: AsyncClient):
  315. admin = await _admin(async_client)
  316. with patch("backend.app.api.routes.smart_plugs.tasmota_service.test_connection", new_callable=AsyncMock) as test:
  317. response = await async_client.post(
  318. "/api/v1/smart-plugs/test-connection",
  319. headers=admin,
  320. json={"ip_address": "10.0.0.5", "username": "admin", "password": M},
  321. )
  322. assert response.status_code == 400, response.text
  323. test.assert_not_awaited()
  324. async def test_a_plug_test_cannot_use_the_secret_of_a_plug_outside_ones_printers(
  325. async_client: AsyncClient, printer_factory
  326. ):
  327. admin = await _admin(async_client)
  328. mine, theirs = await printer_factory(name="Mine"), await printer_factory(name="Theirs")
  329. group = await async_client.post(
  330. "/api/v1/groups/",
  331. headers=admin,
  332. json={
  333. "name": "g_scoped",
  334. "permissions": ["smart_plugs:read", "smart_plugs:control", "smart_plugs:update"],
  335. "restrict_printers": True,
  336. "printer_ids": [mine.id],
  337. },
  338. )
  339. assert group.status_code in (200, 201), group.text
  340. await async_client.post(
  341. "/api/v1/users/",
  342. headers=admin,
  343. json={"username": "scoped", "password": "UserPass1!", "group_ids": [group.json()["id"]]},
  344. )
  345. login = await async_client.post("/api/v1/auth/login", json={"username": "scoped", "password": "UserPass1!"})
  346. scoped = {"Authorization": f"Bearer {login.json()['access_token']}"}
  347. created = await _create_plug(async_client, admin, {**TASMOTA, "printer_id": theirs.id})
  348. with patch("backend.app.api.routes.smart_plugs.tasmota_service.test_connection", new_callable=AsyncMock) as test:
  349. response = await async_client.post(
  350. "/api/v1/smart-plugs/test-connection",
  351. headers=scoped,
  352. json={"ip_address": "10.0.0.5", "username": "admin", "password": M, "plug_id": created["id"]},
  353. )
  354. assert response.status_code == 404, response.text
  355. test.assert_not_awaited()
  356. # --- Obico connection test ---
  357. async def _obico_test(client: AsyncClient, headers: dict[str, str], body: dict):
  358. with patch(
  359. "backend.app.api.routes.obico.obico_detection_service.test_connection",
  360. new_callable=AsyncMock,
  361. return_value={"ok": True, "status_code": 200, "body": "", "error": None, "auth_ok": True},
  362. ) as test:
  363. response = await client.post("/api/v1/obico/test-connection", headers=headers, json=body)
  364. return response, test
  365. @pytest.mark.parametrize("token", [M, None])
  366. async def test_the_obico_test_uses_the_stored_token_for_the_stored_url_only(async_client: AsyncClient, token):
  367. """The placeholder, or no token at all, means the stored token."""
  368. admin = await _admin(async_client)
  369. await _seed_settings(async_client, admin)
  370. body = {} if token is None else {"token": token}
  371. same, test = await _obico_test(async_client, admin, {"url": "http://obico.example:3333", **body})
  372. assert same.status_code == 200, same.text
  373. test.assert_awaited_once_with("http://obico.example:3333", "obico-tok")
  374. moved, test = await _obico_test(async_client, admin, {"url": "http://obico.elsewhere.example:3333", **body})
  375. assert moved.status_code == 400, moved.text
  376. test.assert_not_awaited()
  377. async def test_the_obico_test_with_a_typed_token_tests_that_token(async_client: AsyncClient):
  378. admin = await _admin(async_client)
  379. await _seed_settings(async_client, admin)
  380. response, test = await _obico_test(
  381. async_client, admin, {"url": "http://obico.elsewhere.example:3333", "token": "typed"}
  382. )
  383. assert response.status_code == 200, response.text
  384. test.assert_awaited_once_with("http://obico.elsewhere.example:3333", "typed")
  385. # --- round 2: env HA token, SMTP, LDAP, email security, REST header logs ---
  386. async def test_an_env_ha_token_lets_the_url_change_and_is_never_overwritten(
  387. async_client: AsyncClient, db_session, monkeypatch
  388. ):
  389. """A token from HA_TOKEN is the deployment's, not a stored secret to re-enter:
  390. the URL may change, and the placeholder sent back is not written anywhere."""
  391. admin = await _admin(async_client)
  392. monkeypatch.setenv("HA_TOKEN", "env-ha-tok")
  393. monkeypatch.delenv("HA_URL", raising=False)
  394. response = await _put_settings(async_client, admin, {"ha_url": "http://ha.new.example:8123", "ha_token": M})
  395. assert response.status_code == 200, response.text
  396. assert await _stored_setting(db_session, "ha_url") == "http://ha.new.example:8123"
  397. assert await _stored_setting(db_session, "ha_token") in (None, "")
  398. async def test_a_kept_smtp_password_does_not_follow_a_new_host(async_client: AsyncClient, db_session):
  399. admin = await _admin(async_client)
  400. smtp = {
  401. "smtp_host": "smtp.example",
  402. "smtp_port": 587,
  403. "smtp_username": "u",
  404. "smtp_password": "smtp-pw",
  405. "smtp_security": "starttls",
  406. "smtp_auth_enabled": True,
  407. "smtp_from_email": "a@example.com",
  408. }
  409. assert (await async_client.post("/api/v1/auth/smtp", headers=admin, json=smtp)).status_code == 200
  410. for change in ({"smtp_host": "smtp.elsewhere.example"}, {"smtp_port": 25}, {"smtp_security": "none"}):
  411. moved = await async_client.post(
  412. "/api/v1/auth/smtp", headers=admin, json={**smtp, "smtp_password": None, **change}
  413. )
  414. assert moved.status_code == 400, (change, moved.text)
  415. assert await _stored_setting(db_session, "smtp_host") == "smtp.example"
  416. kept = await async_client.post("/api/v1/auth/smtp", headers=admin, json={**smtp, "smtp_password": None})
  417. assert kept.status_code == 200, kept.text
  418. assert await _stored_setting(db_session, "smtp_password") == "smtp-pw"
  419. entered = await async_client.post(
  420. "/api/v1/auth/smtp", headers=admin, json={**smtp, "smtp_host": "smtp.new.example", "smtp_password": "new-pw"}
  421. )
  422. assert entered.status_code == 200, entered.text
  423. assert await _stored_setting(db_session, "smtp_password") == "new-pw"
  424. async def test_a_new_smtp_host_without_authentication_drops_the_stored_password(async_client: AsyncClient, db_session):
  425. """Nothing to log in with now; the stored password must not be there for later."""
  426. admin = await _admin(async_client)
  427. smtp = {
  428. "smtp_host": "smtp.example",
  429. "smtp_port": 587,
  430. "smtp_username": "u",
  431. "smtp_password": "smtp-pw",
  432. "smtp_security": "starttls",
  433. "smtp_auth_enabled": True,
  434. "smtp_from_email": "a@example.com",
  435. }
  436. await async_client.post("/api/v1/auth/smtp", headers=admin, json=smtp)
  437. response = await async_client.post(
  438. "/api/v1/auth/smtp",
  439. headers=admin,
  440. json={**smtp, "smtp_host": "relay.example", "smtp_password": None, "smtp_auth_enabled": False},
  441. )
  442. assert response.status_code == 200, response.text
  443. assert not await _stored_setting(db_session, "smtp_password")
  444. async def test_a_kept_ldap_bind_password_does_not_follow_a_new_server(async_client: AsyncClient, db_session):
  445. admin = await _admin(async_client)
  446. await _put_settings(
  447. async_client,
  448. admin,
  449. {"ldap_server_url": "ldaps://ldap.example", "ldap_security": "ldaps", "ldap_bind_password": "bind-pw"},
  450. )
  451. for change in ({"ldap_server_url": "ldap://ldap.elsewhere.example"}, {"ldap_security": "starttls"}):
  452. response = await _put_settings(async_client, admin, change)
  453. assert response.status_code == 400, (change, response.text)
  454. assert await _stored_setting(db_session, "ldap_server_url") == "ldaps://ldap.example"
  455. response = await _put_settings(
  456. async_client, admin, {"ldap_server_url": "ldaps://ldap.new.example", "ldap_bind_password": "new-bind"}
  457. )
  458. assert response.status_code == 200, response.text
  459. assert await _stored_setting(db_session, "ldap_bind_password") == "new-bind"
  460. async def test_malformed_rest_headers_are_not_logged(caplog):
  461. from backend.app.services.rest_smart_plug import rest_smart_plug_service
  462. with caplog.at_level("DEBUG"):
  463. assert rest_smart_plug_service._parse_headers('{"Authorization": "Bearer leak-me",}') == {}
  464. assert "leak-me" not in caplog.text
  465. assert "REST headers" in caplog.text