test_log_reader_device_urls.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. """Old log lines naming a REST plug's URL come back with its host only.
  2. Earlier versions logged the whole URL when switching or polling a REST plug,
  3. from the plug service and from the HTTP client. The URL's path and query can be
  4. the device's credential, in shapes no pattern recognises, so lines from those
  5. two loggers are reduced to scheme and host when read.
  6. """
  7. from backend.app.core.config import settings
  8. from backend.app.services.log_reader import read_log_entries, sanitize_log_content
  9. PLUG = (
  10. "2026-10-01 10:00:00,000 INFO [backend.app.services.rest_smart_plug] [x] "
  11. "Turned ON REST smart plug 'P' via GET http://plug.elsewhere.example:8080/relay/0?turn=on&auth_key=old-value-1"
  12. )
  13. CLIENT = (
  14. "2026-10-01 10:00:01,000 INFO [httpx] [x] "
  15. 'HTTP Request: GET http://ha.elsewhere.example:8123/api/webhook/old-value-2 "HTTP/1.1 200 OK"'
  16. )
  17. LOGIN = (
  18. "2026-10-01 10:00:02,000 WARNING [backend.app.services.rest_smart_plug] [x] "
  19. "REST smart plug at http://admin:p?old-value-3@plug.elsewhere.example/x timed out"
  20. )
  21. OTHER = (
  22. "2026-10-01 10:00:03,000 INFO [backend.app.services.spoolman] [x] "
  23. "Fetched http://spoolman.elsewhere.example/api/v1/spool"
  24. )
  25. SECRETS = ("old-value-1", "old-value-2", "old-value-3")
  26. def _write(tmp_path, monkeypatch):
  27. (tmp_path / "bambuddy.log").write_text("\n".join([PLUG, CLIENT, LOGIN, OTHER]) + "\n", encoding="utf-8")
  28. monkeypatch.setattr(settings, "log_dir", tmp_path)
  29. def test_old_device_lines_come_back_with_the_host_only(tmp_path, monkeypatch):
  30. _write(tmp_path, monkeypatch)
  31. entries, _total = read_log_entries(limit=50)
  32. text = "\n".join(e.message for e in entries)
  33. assert not [secret for secret in SECRETS if secret in text]
  34. assert "via GET http://plug.elsewhere.example:8080" in text
  35. assert "GET http://ha.elsewhere.example:8123 " in text
  36. assert "http://spoolman.elsewhere.example/api/v1/spool" in text
  37. def test_a_search_cannot_confirm_a_device_secret(tmp_path, monkeypatch):
  38. _write(tmp_path, monkeypatch)
  39. for secret in SECRETS:
  40. entries, _total = read_log_entries(limit=50, search=secret)
  41. assert entries == [], secret
  42. def test_the_support_bundle_keeps_the_host_only():
  43. content = sanitize_log_content("\n".join([PLUG, CLIENT, LOGIN, OTHER]))
  44. assert not [secret for secret in SECRETS if secret in content]
  45. assert "spoolman.elsewhere.example/api/v1/spool" in content
  46. QUOTED = (
  47. "2026-10-01 10:00:04,000 INFO [backend.app.services.rest_smart_plug] [x] "
  48. "Turned ON REST smart plug 'P' via GET http://plug.elsewhere.example/api/webhook/ab'old-value-4?auth_key=old-value-5"
  49. )
  50. QUOTED_LOGIN = (
  51. "2026-10-01 10:00:05,000 WARNING [backend.app.services.rest_smart_plug] [x] "
  52. "REST smart plug at http://admin:pa'old-value-6@plug.elsewhere.example/api/webhook/old-value-7 timed out"
  53. )
  54. QUOTED_SECRETS = ("old-value-4", "old-value-5", "old-value-6", "old-value-7")
  55. def test_a_quote_inside_an_old_url_does_not_end_it(tmp_path, monkeypatch):
  56. (tmp_path / "bambuddy.log").write_text("\n".join([QUOTED, QUOTED_LOGIN]) + "\n", encoding="utf-8")
  57. monkeypatch.setattr(settings, "log_dir", tmp_path)
  58. entries, _total = read_log_entries(limit=50)
  59. bundle = sanitize_log_content("\n".join([QUOTED, QUOTED_LOGIN]))
  60. text = "\n".join(e.message for e in entries)
  61. assert not [secret for secret in QUOTED_SECRETS if secret in text]
  62. assert not [secret for secret in QUOTED_SECRETS if secret in bundle]
  63. assert "admin" not in text and "admin" not in bundle
  64. for secret in QUOTED_SECRETS:
  65. assert read_log_entries(limit=50, search=secret)[0] == [], secret
  66. def test_a_known_value_inside_the_logger_name_does_not_unmask_the_line():
  67. content = sanitize_log_content("\n".join([PLUG, CLIENT]), {"smart": "[USER]", "httpx": "[USER]"})
  68. assert not [secret for secret in SECRETS if secret in content]
  69. HEADER = (
  70. "2026-10-01 10:00:06,000 WARNING [backend.app.services.rest_smart_plug] [x] "
  71. "Failed to connect to REST smart plug at http://plug.elsewhere.example/x: "
  72. "Illegal header value b'Bearer old-value-8 '"
  73. )
  74. AT_AFTER_HOST = (
  75. "2026-10-01 10:00:07,000 WARNING [backend.app.services.rest_smart_plug] [x] "
  76. "REST smart plug at http://admin:pa@old-value-9?more@plug.elsewhere.example/x timed out"
  77. )
  78. def test_an_old_header_error_and_an_at_after_the_host_come_back_masked(tmp_path, monkeypatch):
  79. (tmp_path / "bambuddy.log").write_text("\n".join([HEADER, AT_AFTER_HOST]) + "\n", encoding="utf-8")
  80. monkeypatch.setattr(settings, "log_dir", tmp_path)
  81. text = "\n".join(e.message for e in read_log_entries(limit=50)[0])
  82. bundle = sanitize_log_content("\n".join([HEADER, AT_AFTER_HOST]))
  83. for secret in ("old-value-8", "old-value-9"):
  84. assert secret not in text and secret not in bundle, secret
  85. assert read_log_entries(limit=50, search=secret)[0] == [], secret
  86. # Every message the REST plug service has logged in a shipped version, with a
  87. # secret in every slot that held a URL, an error's text or raw input.
  88. _PREFIX = "2026-10-01 10:00:{:02d},000 WARNING [backend.app.services.rest_smart_plug] [x] "
  89. HISTORICAL = [
  90. "Unexpected error communicating with REST smart plug at http://plug.example/sec-01: odd sec-02",
  91. "Turned OFF REST smart plug 'P' via GET http://plug.example/relay?auth_key=sec-03",
  92. "Turned ON REST smart plug 'P' via GET http://plug.example/api/webhook/sec-04",
  93. "Blocked REST request to invalid URL: 192.168.1.5/api/webhook/sec-05",
  94. "Blocked REST request to invalid URL: admin:sec-06@192.168.1.5/relay",
  95. "Failed to connect to REST smart plug at http://plug.example/sec-07: cannot reach sec-08",
  96. "Failed to parse REST headers JSON; sending none",
  97. "Failed to parse REST headers JSON: Authorization: Bearer sec-09",
  98. "Failed to parse REST headers JSON: {'X-Api-Key': 'sec-10'}",
  99. "Failed to turn OFF REST smart plug 'P'",
  100. "Failed to turn ON REST smart plug 'P'",
  101. "No OFF URL configured for REST plug 'P'",
  102. "No ON URL configured for REST plug 'P'",
  103. "REST smart plug at http://plug.example/sec-11 returned error: Server error for url 'http://plug.example/sec-12' sec-13",
  104. "REST smart plug at http://plug.example/sec-14 timed out",
  105. ]
  106. HISTORICAL_SECRETS = [f"sec-{n:02d}" for n in range(1, 15)]
  107. def test_every_message_the_plug_service_ever_logged_comes_back_without_its_secrets(tmp_path, monkeypatch):
  108. lines = [_PREFIX.format(n) + message for n, message in enumerate(HISTORICAL)]
  109. (tmp_path / "bambuddy.log").write_text("\n".join(lines) + "\n", encoding="utf-8")
  110. monkeypatch.setattr(settings, "log_dir", tmp_path)
  111. entries, _total = read_log_entries(limit=100)
  112. text = "\n".join(e.message for e in entries)
  113. bundle = sanitize_log_content("\n".join(lines))
  114. assert len(entries) == len(HISTORICAL)
  115. for secret in HISTORICAL_SECRETS:
  116. assert secret not in text, secret
  117. assert secret not in bundle, secret
  118. assert read_log_entries(limit=100, search=secret)[0] == [], secret
  119. assert "Turned ON REST smart plug 'P' via GET http://plug.example" in text
  120. def test_a_refused_header_value_is_masked_from_any_logger(tmp_path, monkeypatch):
  121. """An HTTP client refusing a header quotes its value, which is commonly a
  122. token, whichever service sent it."""
  123. line = (
  124. "2026-10-01 10:00:30,000 WARNING [backend.app.services.homeassistant] [x] "
  125. "Failed to get HA entity state for switch.x: Illegal header value b'Bearer hdr-value-1 '"
  126. )
  127. name = (
  128. "2026-10-01 10:00:31,000 WARNING [backend.app.services.notifications] [x] "
  129. "Send failed: Illegal header name b'X-hdr-value-2'"
  130. )
  131. (tmp_path / "bambuddy.log").write_text(f"{line}\n{name}\n", encoding="utf-8")
  132. monkeypatch.setattr(settings, "log_dir", tmp_path)
  133. text = "\n".join(e.message for e in read_log_entries(limit=50)[0])
  134. bundle = sanitize_log_content(f"{line}\n{name}")
  135. for secret in ("hdr-value-1", "hdr-value-2"):
  136. assert secret not in text and secret not in bundle, secret
  137. assert read_log_entries(limit=50, search=secret)[0] == [], secret
  138. assert "Failed to get HA entity state for switch.x: Illegal header value" in text