test_settings_and_plug_secrets.py 24 KB

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