Files
bonaminandClaude Opus 5.5 3acd89a2c6 test(mqtt-auth): cover /mqtt/auth/user and /mqtt/auth/acl
First pytest suite in the backend (backend/tests/, run from backend/ with
`python -m pytest tests`). firebase_admin.verify_id_token and Firestore
are mocked, so no network access is needed. 82 cases:

- /user devices: HMAC ok / wrong / other serial's HMAC, kiosk HMAC,
  legacy password with flag on/off, legacy rejected for non-device-shaped
  usernames and for app_ users, HMAC rejected for app_ users, legacy-login
  log rate limiting.
- /user app users: valid token (asserts check_revoked=True), token for a
  different uid, revoked, expired, blocked user, unknown uid, empty uid,
  and that a denied token never appears in logs.
- /acl app users: acc 1/2/4 allow/deny per topic, unsupported acc values,
  wildcards, foreign serial, malformed topics, wrong clientid prefixes
  (incl. uid-prefix collision), blocked/unknown users, cache hit +
  invalidate, cache expiry.
- /acl devices/kiosk/superuser: unchanged behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:04:37 +03:00

358 lines
12 KiB
Python

"""
Tests for the mosquitto-go-auth HTTP backend endpoints (mqtt/auth.py):
POST /mqtt/auth/user and POST /mqtt/auth/acl.
firebase_admin token verification and Firestore are mocked; nothing here
touches the network.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from firebase_admin import auth as firebase_auth
from config import settings
from mqtt import app_users
from mqtt import auth as mqtt_auth
UID = "uidAlice123"
OTHER_UID = "uidBob456"
SERIAL = "BSVSPR-26C13X-STD01R-X7KQA"
FOREIGN_SERIAL = "PV25L22BP01R01"
APP_USER = f"app_{UID}"
APP_CLIENT = f"app_{UID}_phone1"
GOOD_TOKEN = "good-token"
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _FakeDoc:
def __init__(self, doc_id, data):
self.id = doc_id
self._data = data
def to_dict(self):
return dict(self._data)
class _FakeQuery:
def __init__(self, docs, field=None, value=None):
self._docs = docs
self._field = field
self._value = value
def where(self, field, op, value):
assert op == "=="
return _FakeQuery(self._docs, field, value)
def limit(self, _n):
return self
def stream(self):
return [d for d in self._docs if d.to_dict().get(self._field) == self._value]
class FakeFirestore:
"""Just enough of the Firestore client for app_users._load()."""
def __init__(self):
self.users: list[_FakeDoc] = []
self.reads = 0
def add_user(self, doc_id, **data):
self.users.append(_FakeDoc(doc_id, data))
def collection(self, name):
assert name == "users"
self.reads += 1
return _FakeQuery(self.users)
@pytest.fixture
def fs(monkeypatch):
db = FakeFirestore()
# Doc ID deliberately != uid, like Console-created users.
db.add_user("randomDocId1", uid=UID, status="active", device_serials=[SERIAL])
db.add_user(OTHER_UID, uid=OTHER_UID, status="blocked", device_serials=[FOREIGN_SERIAL])
monkeypatch.setattr(app_users, "get_db", lambda: db)
app_users.clear_cache()
yield db
app_users.clear_cache()
@pytest.fixture
def verify(monkeypatch):
"""Mock verify_id_token. Set `verify.result` to a dict or an exception."""
class _Verify:
result = {"uid": UID}
calls = []
def __call__(self, token, check_revoked=False):
self.calls.append((token, check_revoked))
if isinstance(self.result, Exception):
raise self.result
return self.result
v = _Verify()
v.calls = []
monkeypatch.setattr(mqtt_auth.firebase_auth, "verify_id_token", v)
return v
@pytest.fixture
def client():
app = FastAPI()
app.include_router(mqtt_auth.router)
return TestClient(app)
@pytest.fixture(autouse=True)
def _defaults(monkeypatch):
monkeypatch.setattr(settings, "mqtt_secret", "test-secret")
monkeypatch.setattr(settings, "mqtt_allow_legacy_password", True)
mqtt_auth._legacy_log_last.clear()
def auth_user(client, username, password, clientid="c1"):
return client.post(
"/mqtt/auth/user",
data={"username": username, "password": password, "clientid": clientid},
).status_code
def acl(client, username, topic, acc, clientid=APP_CLIENT):
return client.post(
"/mqtt/auth/acl",
data={"username": username, "topic": topic, "clientid": clientid, "acc": acc},
).status_code
# ---------------------------------------------------------------------------
# /mqtt/auth/user — devices
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("username", [SERIAL, FOREIGN_SERIAL, f"{FOREIGN_SERIAL}-kiosk"])
def test_device_hmac_ok(client, username):
assert auth_user(client, username, mqtt_auth._derive_password(username)) == 200
def test_device_hmac_wrong_password(client):
assert auth_user(client, SERIAL, "0" * 32) == 403
def test_device_hmac_of_other_serial_rejected(client):
assert auth_user(client, SERIAL, mqtt_auth._derive_password(FOREIGN_SERIAL)) == 403
def test_legacy_password_accepted_when_flag_on(client):
assert auth_user(client, FOREIGN_SERIAL, "vesper") == 200
assert auth_user(client, f"{FOREIGN_SERIAL}-kiosk", "vesper") == 200
def test_legacy_password_rejected_when_flag_off(client, monkeypatch):
monkeypatch.setattr(settings, "mqtt_allow_legacy_password", False)
assert auth_user(client, FOREIGN_SERIAL, "vesper") == 403
# HMAC still works with the flag off
assert auth_user(client, FOREIGN_SERIAL, mqtt_auth._derive_password(FOREIGN_SERIAL)) == 200
@pytest.mark.parametrize("username", ["somebody", "NodeRED", "pv25l22bp01r01", "PV-", "PV 25"])
def test_legacy_password_rejected_for_non_device_usernames(client, username):
assert auth_user(client, username, "vesper") == 403
def test_legacy_password_never_accepted_for_app_users(client, fs, verify):
verify.result = firebase_auth.InvalidIdTokenError("not a jwt")
assert auth_user(client, APP_USER, "vesper") == 403
def test_hmac_never_accepted_for_app_users(client, fs, verify):
verify.result = firebase_auth.InvalidIdTokenError("not a jwt")
assert auth_user(client, APP_USER, mqtt_auth._derive_password(APP_USER)) == 403
def test_legacy_login_logged_once_per_hour(client, caplog):
with caplog.at_level("WARNING", logger="mqtt.auth"):
for _ in range(3):
assert auth_user(client, FOREIGN_SERIAL, "vesper") == 200
assert auth_user(client, SERIAL, "vesper") == 200
legacy = [r for r in caplog.records if "legacy password" in r.getMessage()]
assert [r.getMessage().split(" for ")[1].split(" ")[0] for r in legacy] == [FOREIGN_SERIAL, SERIAL]
# ---------------------------------------------------------------------------
# /mqtt/auth/user — app users
# ---------------------------------------------------------------------------
def test_app_valid_token(client, fs, verify):
assert auth_user(client, APP_USER, GOOD_TOKEN) == 200
assert verify.calls == [(GOOD_TOKEN, True)] # check_revoked=True
def test_app_token_for_other_uid(client, fs, verify):
verify.result = {"uid": OTHER_UID}
assert auth_user(client, APP_USER, GOOD_TOKEN) == 403
def test_app_revoked_token(client, fs, verify):
verify.result = firebase_auth.RevokedIdTokenError("revoked")
assert auth_user(client, APP_USER, GOOD_TOKEN) == 403
def test_app_expired_token(client, fs, verify):
verify.result = firebase_auth.ExpiredIdTokenError("expired", cause=None)
assert auth_user(client, APP_USER, GOOD_TOKEN) == 403
def test_app_blocked_user(client, fs, verify):
verify.result = {"uid": OTHER_UID}
assert auth_user(client, f"app_{OTHER_UID}", GOOD_TOKEN) == 403
def test_app_unknown_user(client, fs, verify):
verify.result = {"uid": "ghost"}
assert auth_user(client, "app_ghost", GOOD_TOKEN) == 403
def test_app_empty_uid(client, fs, verify):
verify.result = {"uid": ""}
assert auth_user(client, "app_", GOOD_TOKEN) == 403
def test_app_deny_never_logs_token(client, fs, verify, caplog):
secret_token = "eyJhbGciOi.SECRET-TOKEN-VALUE.sig"
verify.result = {"uid": OTHER_UID}
with caplog.at_level("DEBUG"):
assert auth_user(client, APP_USER, secret_token) == 403
assert "token uid does not match" in caplog.text
assert secret_token not in caplog.text
# ---------------------------------------------------------------------------
# /mqtt/auth/acl — app users
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("leaf", ["control/ack", "status/heartbeat", "status/playback"])
@pytest.mark.parametrize("acc", [mqtt_auth.ACC_READ, mqtt_auth.ACC_SUBSCRIBE])
def test_app_acl_read_subscribe_allowed(client, fs, leaf, acc):
assert acl(client, APP_USER, f"vesper/{SERIAL}/{leaf}", acc) == 200
def test_app_acl_publish_command_allowed(client, fs):
assert acl(client, APP_USER, f"vesper/{SERIAL}/control/command", mqtt_auth.ACC_WRITE) == 200
@pytest.mark.parametrize("leaf", ["control/ack", "status/heartbeat", "status/playback", "system/info"])
def test_app_acl_publish_other_topics_denied(client, fs, leaf):
assert acl(client, APP_USER, f"vesper/{SERIAL}/{leaf}", mqtt_auth.ACC_WRITE) == 403
@pytest.mark.parametrize("acc", [mqtt_auth.ACC_READ, mqtt_auth.ACC_SUBSCRIBE])
@pytest.mark.parametrize("leaf", ["control/command", "system/info", "system/alerts", "control/reports"])
def test_app_acl_read_subscribe_other_topics_denied(client, fs, acc, leaf):
assert acl(client, APP_USER, f"vesper/{SERIAL}/{leaf}", acc) == 403
@pytest.mark.parametrize("acc", [0, 3, 5, 8])
def test_app_acl_unknown_acc_denied(client, fs, acc):
assert acl(client, APP_USER, f"vesper/{SERIAL}/control/ack", acc) == 403
@pytest.mark.parametrize("topic", [
f"vesper/{SERIAL}/#",
f"vesper/{SERIAL}/status/+",
"vesper/+/control/ack",
"#",
])
@pytest.mark.parametrize("acc", [mqtt_auth.ACC_READ, mqtt_auth.ACC_WRITE, mqtt_auth.ACC_SUBSCRIBE])
def test_app_acl_wildcards_denied(client, fs, topic, acc):
assert acl(client, APP_USER, topic, acc) == 403
@pytest.mark.parametrize("acc", [mqtt_auth.ACC_READ, mqtt_auth.ACC_WRITE, mqtt_auth.ACC_SUBSCRIBE])
def test_app_acl_foreign_serial_denied(client, fs, acc):
leaf = "control/command" if acc == mqtt_auth.ACC_WRITE else "control/ack"
assert acl(client, APP_USER, f"vesper/{FOREIGN_SERIAL}/{leaf}", acc) == 403
@pytest.mark.parametrize("clientid", [
"",
"phone1",
f"app_{UID}", # missing trailing "_"
f"app_{OTHER_UID}_x", # another user's prefix
f"app_{UID}x_phone", # uid prefix collision
])
def test_app_acl_wrong_clientid_denied(client, fs, clientid):
assert acl(client, APP_USER, f"vesper/{SERIAL}/control/ack", mqtt_auth.ACC_SUBSCRIBE, clientid=clientid) == 403
@pytest.mark.parametrize("topic", [
f"vesper/{SERIAL}/control/command/extra",
f"vesper/{SERIAL}/control",
f"other/{SERIAL}/control/command",
"vesper//control/command",
])
def test_app_acl_malformed_topics_denied(client, fs, topic):
assert acl(client, APP_USER, topic, mqtt_auth.ACC_WRITE) == 403
def test_app_acl_blocked_user_denied(client, fs):
user = f"app_{OTHER_UID}"
assert acl(client, user, f"vesper/{FOREIGN_SERIAL}/control/ack", mqtt_auth.ACC_SUBSCRIBE,
clientid=f"{user}_phone") == 403
def test_app_acl_unknown_user_denied(client, fs):
assert acl(client, "app_ghost", f"vesper/{SERIAL}/control/ack", mqtt_auth.ACC_SUBSCRIBE,
clientid="app_ghost_phone") == 403
def test_app_acl_uses_cache_and_invalidate_refreshes(client, fs):
topic = f"vesper/{SERIAL}/control/ack"
assert acl(client, APP_USER, topic, mqtt_auth.ACC_SUBSCRIBE) == 200
reads = fs.reads
for _ in range(5):
assert acl(client, APP_USER, topic, mqtt_auth.ACC_READ) == 200
assert fs.reads == reads # served from cache
# Unassign the device; the cached entry still allows until invalidated.
fs.users[0]._data["device_serials"] = []
assert acl(client, APP_USER, topic, mqtt_auth.ACC_READ) == 200
app_users.invalidate(UID)
assert acl(client, APP_USER, topic, mqtt_auth.ACC_READ) == 403
def test_app_acl_cache_expires(client, fs, monkeypatch):
topic = f"vesper/{SERIAL}/control/ack"
assert acl(client, APP_USER, topic, mqtt_auth.ACC_READ) == 200
fs.users[0]._data["device_serials"] = []
clock = app_users.time.monotonic() + app_users.CACHE_TTL_SECONDS + 1
monkeypatch.setattr(app_users.time, "monotonic", lambda: clock)
assert acl(client, APP_USER, topic, mqtt_auth.ACC_READ) == 403
# ---------------------------------------------------------------------------
# /mqtt/auth/acl — devices (unchanged behaviour)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("acc", [1, 2, 4])
def test_device_acl_own_topics(client, acc):
assert acl(client, SERIAL, f"vesper/{SERIAL}/status/heartbeat", acc, clientid="") == 200
@pytest.mark.parametrize("acc", [1, 2, 4])
def test_device_acl_foreign_topics_denied(client, acc):
assert acl(client, SERIAL, f"vesper/{FOREIGN_SERIAL}/status/heartbeat", acc, clientid="") == 403
def test_kiosk_acl_base_device_topics(client):
assert acl(client, f"{FOREIGN_SERIAL}-kiosk", f"vesper/{FOREIGN_SERIAL}/control/command", 2, clientid="") == 200
assert acl(client, f"{FOREIGN_SERIAL}-kiosk", f"vesper/{SERIAL}/control/command", 2, clientid="") == 403
def test_superuser_acl(client):
assert acl(client, "NodeRED", "vesper/#", 4, clientid="") == 200