This commit is contained in:
Jonas Bengtsson
2026-08-31 22:39:20 +02:00
parent 26a027d1e8
commit ac52671b10
8 changed files with 367 additions and 60 deletions
+124 -32
View File
@@ -200,40 +200,47 @@ class TestLoadImeiMap:
load_imei_map(str(path))
@pytest.fixture()
def app_module(tmp_path):
"""Import api.app with a prepared environment (create_app runs at import
time). Yields the reloaded module and restores the environment after."""
import importlib
import os
# connect_timeout keeps the DB-connecting tests fast even when
# localhost:5432 is silently dropped by a firewall (psycopg otherwise
# waits out the OS-level TCP timeout).
tokens = tmp_path / "app_tokens.json"
tokens.write_text(
json.dumps({"tokens": {"test-app": "secret-abc"}}), encoding="utf-8"
)
imei_map = tmp_path / "imei_map.json"
imei_map.write_text(
json.dumps({"350123456789012": "Greenhouse sensor A"}), encoding="utf-8"
)
env = {
"APP_TOKENS_FILE": str(tokens),
"IMEI_MAP_FILE": str(imei_map),
"DB_DSN": "postgresql://user:pass@localhost:5432/db?connect_timeout=2",
}
old = {key: os.environ.get(key) for key in env}
os.environ.update(env)
try:
import api.app as app_module
importlib.reload(app_module)
yield app_module
finally:
for key, value in old.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
class TestApiAuth:
"""Light tests of the API auth dependency using the ASGI app directly."""
@pytest.fixture()
def app_module(self, tmp_path):
import importlib
import os
# api.app runs create_app() at import time, so the environment must be
# prepared before importing. connect_timeout keeps the DB-connecting
# tests fast even when localhost:5432 is silently dropped by a firewall
# (psycopg otherwise waits out the OS-level TCP timeout).
tokens = tmp_path / "app_tokens.json"
tokens.write_text(
json.dumps({"tokens": {"test-app": "secret-abc"}}), encoding="utf-8"
)
env = {
"APP_TOKENS_FILE": str(tokens),
"DB_DSN": "postgresql://user:pass@localhost:5432/db?connect_timeout=2",
}
old = {key: os.environ.get(key) for key in env}
os.environ.update(env)
try:
import api.app as app_module
importlib.reload(app_module)
yield app_module
finally:
for key, value in old.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
def test_health_is_public(self, app_module) -> None:
from fastapi.testclient import TestClient
@@ -289,4 +296,89 @@ class TestApiAuth:
params={"from": value},
headers={"X-App-Token": "secret-abc"},
)
assert response.status_code != 422
assert response.status_code != 422
class TestConfigEndpoints:
"""GET/PUT/POST /configs/{name} — reads, validated JSON writes."""
@pytest.fixture()
def client(self, app_module):
from fastapi.testclient import TestClient
return TestClient(app_module.app)
HDRS = {"X-App-Token": "secret-abc"}
def test_get_imei_map(self, client, app_module) -> None:
r = client.get("/configs/imei_map", headers=self.HDRS)
assert r.status_code == 200
assert r.json() == {"350123456789012": "Greenhouse sensor A"}
def test_get_app_tokens(self, client) -> None:
r = client.get("/configs/app_tokens", headers=self.HDRS)
assert r.status_code == 200
assert r.json() == {"tokens": {"test-app": "secret-abc"}}
def test_get_unknown_config_404(self, client) -> None:
r = client.get("/configs/does-not-exist", headers=self.HDRS)
assert r.status_code == 404
def test_config_endpoints_require_token(self, client) -> None:
assert client.get("/configs/imei_map").status_code == 401
assert client.put("/configs/imei_map", json={}).status_code == 401
def test_put_valid_imei_map(self, client, app_module) -> None:
import os
body = {"350123456789012": "Greenhouse sensor A", "490154203237518": "Water tank sensor B"}
r = client.put("/configs/imei_map", json=body, headers=self.HDRS)
assert r.status_code == 200
assert r.json() == {"status": "updated", "config": "imei_map"}
# file on disk holds the new content
with open(os.environ["IMEI_MAP_FILE"], encoding="utf-8") as f:
assert json.load(f) == body
def test_post_is_alias_for_put(self, client) -> None:
body = {"350123456789012": "Greenhouse sensor A"}
r = client.post("/configs/imei_map", json=body, headers=self.HDRS)
assert r.status_code == 200
def test_put_rejects_non_imei_keys(self, client) -> None:
r = client.put("/configs/imei_map", json={"0011223344556677": "X"}, headers=self.HDRS)
assert r.status_code == 422
assert "15-digit IMEI" in r.json()["detail"]
def test_put_rejects_non_string_values(self, client) -> None:
r = client.put("/configs/imei_map", json={"350123456789012": 42}, headers=self.HDRS)
assert r.status_code == 422
def test_put_rejects_bad_token_file_shape(self, client) -> None:
# JSON object, but not the {"tokens": {...}} shape
r = client.put("/configs/app_tokens", json={"foo": "bar"}, headers=self.HDRS)
assert r.status_code == 422
def test_put_rejects_empty_token_secret(self, client) -> None:
r = client.put(
"/configs/app_tokens", json={"tokens": {"app": ""}}, headers=self.HDRS
)
assert r.status_code == 422
def test_non_object_body_rejected_by_fastapi(self, client) -> None:
# JSON but not an object: FastAPI's dict[str, Any] annotation rejects
# arrays and scalars before the handler runs
r = client.put("/configs/imei_map", json=[1, 2, 3], headers=self.HDRS)
assert r.status_code == 422
r = client.put("/configs/imei_map", json="just a string", headers=self.HDRS)
assert r.status_code == 422
def test_app_tokens_update_reloads_auth(self, client, app_module) -> None:
# rotate the token, then authenticate with the NEW one
new_tokens = {"tokens": {"test-app": "secret-abc", "rotated": "fresh-secret"}}
r = client.put("/configs/app_tokens", json=new_tokens, headers=self.HDRS)
assert r.status_code == 200
r = client.get("/configs/app_tokens", headers={"X-App-Token": "fresh-secret"})
assert r.status_code == 200
# the old token still works too (it was kept in the file)
r = client.get("/configs/app_tokens", headers=self.HDRS)
assert r.status_code == 200