🚧 Add handling of static metadata
This commit is contained in:
@@ -79,7 +79,8 @@ PASS: count endpoint returned {'count': N}
|
||||
|
||||
- The IMEI is the **last segment** of the topic, e.g. `/vivab/350123456789012` → `350123456789012`.
|
||||
- It must be exactly **15 digits**; anything else (letters, separators, other lengths) is skipped.
|
||||
- It looks the IMEI up in the `imei_map` table; on a match the device name is injected, otherwise the IMEI itself is used as the device name. The listener re-reads the table every 5 seconds, so API edits take effect without a restart.
|
||||
- It looks the IMEI up in the `imei_map` table and injects the device's settings document's `device` value as the device name; on no mapping the IMEI itself is used. The listener re-reads the table every 5 seconds, so API edits take effect without a restart.
|
||||
- Each device's **settings document** (JSONB) can hold more than the name — e.g. `{"device": "Greenhouse sensor A", "location": "Växthus 1"}`. The listener only uses `device` at ingest; the API merges the whole current document into message payloads at read time (see below).
|
||||
- The injected keys are `imei` and `device` (configurable via `INJECT_IMEI_KEY` / `INJECT_DEVICE_KEY`). Any existing values in the payload are **overwritten** — the topic is authoritative.
|
||||
- A **device timestamp** is extracted at ingest time from the first parseable payload key in `PAYLOAD_TIME_KEYS` (default `timestamp,time,ts`; ISO 8601 strings, epoch seconds and epoch milliseconds all accepted) and stored in the `payload_time` column. A missing or unparseable timestamp does **not** skip the message — `payload_time` is simply NULL and `received_at` (server receipt time) remains authoritative.
|
||||
- The payload can be a **single JSON object or a JSON array of objects**. Each object in an array is injected and stored as its own database row (each with its own `payload_time`); array elements that are not objects are skipped.
|
||||
@@ -93,9 +94,16 @@ Authentication: pass one of the configured app tokens either as the `X-App-Token
|
||||
| -------- | ---- | ----------- |
|
||||
| `GET /health` | none | Liveness probe (public) |
|
||||
| `GET /messages/count` | token | Total number of stored messages |
|
||||
| `GET /messages` | token | List messages, newest first. Query parameters: `imei`, `device` (exact-match filters), `from`/`to` (ISO 8601 range on `received_at` — server receipt time; inclusive; naive values treated as UTC), `payload_from`/`payload_to` (same but on `payload_time` — the device timestamp extracted from the JSON; messages without one are excluded; ordering switches to `payload_time DESC`), `limit` (default 100, capped at `API_MAX_LIMIT`), `offset` for paging. Reversed ranges return 422 |
|
||||
| `GET /configs/{name}` | token | Read a config as JSON. `name`: `app_tokens` (secrets are not recoverable — app names with `null` placeholders) or `imei_map` (the full mapping) |
|
||||
| `PUT/POST /configs/{name}` | token | Replace a config in the database. Body **must be a JSON object**; per-config schema is validated (imei_map keys must be 15-digit IMEIs; app_tokens must keep the `{"tokens": {name: secret}}` shape). Invalid payloads → 422, unknown names → 404. Writes are transactional (delete + insert). `app_tokens` changes take effect immediately; `imei_map` changes are picked up by the listener within ~5 seconds |
|
||||
| `GET /messages` | token | List messages, newest first. The device's **current settings document** (from `imei_map`) is merged into each item's `payload`; on duplicate keys the stored payload wins. Query parameters: `imei`, `device` (exact-match filters), `from`/`to` (ISO 8601 range on `received_at` — server receipt time; inclusive; naive values treated as UTC), `payload_from`/`payload_to` (same but on `payload_time` — the device timestamp extracted from the JSON; messages without one are excluded; ordering switches to `payload_time DESC`), `limit` (default 100, capped at `API_MAX_LIMIT`), `offset` for paging. Reversed ranges return 422 |
|
||||
| `GET /configs/{name}` | token | Read a config as JSON. `name`: `app_tokens` (secrets are not recoverable — app names with `null` placeholders) or `imei_map` (all device settings documents) |
|
||||
| `PUT/POST /configs/{name}` | token | **Bulk replace** a config in the database (delete + insert, transactional). Body **must be a JSON object**; per-config schema is validated (`imei_map` keys must be 15-digit IMEIs, values are settings documents — flat `"name"` values are promoted to `{"device": "name"}`; `app_tokens` must keep the `{"tokens": {name: secret}}` shape). Invalid payloads → 422, unknown names → 404. `app_tokens` changes take effect immediately; `imei_map` changes are picked up by the listener within ~5 seconds |
|
||||
| `GET /devices` | token | All devices, each as `{"imei": ..., **settings}` |
|
||||
| `GET /devices/{imei}` | token | One device's settings document; unknown IMEI → 404, non-15-digit IMEI → 422 |
|
||||
| `PUT /devices/{imei}` | token | **Upsert one device's settings document** (create or replace). Body is the settings object (must have a non-empty `device` key; extra keys are arbitrary JSON); a body `imei` key is tolerated when it matches the URL. Returns the saved document, so a GET response can be edited and PUT straight back |
|
||||
| `DELETE /devices/{imei}` | token | Remove one device (its messages stay); unknown IMEI → 404 |
|
||||
| `GET /tokens` | token | App names with a token (secrets are unrecoverable, shown as `null`) |
|
||||
| `PUT /tokens/{app}` | token | **Add or rotate ONE app token** (single-row upsert — never touches the other apps' rows). Body: `{"token": "secret"}`. Takes effect immediately |
|
||||
| `DELETE /tokens/{app}` | token | Remove one app token; unknown name → 404. Deleting the last token locks the API until a new one is added |
|
||||
| `GET /docs`, `GET /redoc` | none | OpenAPI docs (public) |
|
||||
|
||||
When the database is unreachable, authenticated endpoints return **503** (the API has no purpose without the db).
|
||||
@@ -108,25 +116,32 @@ GET /messages?device=Water%20tank%20sensor%20B&payload_from=2026-08-31T00:00:00Z
|
||||
|
||||
### Managing config through the API
|
||||
|
||||
Both configs live in the database (`app_tokens`, `imei_map` tables) and can be read and replaced over HTTP (authenticated like all other endpoints). This is how to add a device or rotate a token without direct database access:
|
||||
Both configs live in the database (`app_tokens`, `imei_map` tables). **A management frontend should use the per-resource endpoints** (`/devices/{imei}`, `/tokens/{app}`) — they save one row at a time, so two users editing different devices (or an admin rotating one token) never clobber each other. The whole-document `PUT /configs/{name}` endpoints remain for bulk imports:
|
||||
|
||||
```bash
|
||||
# read the current IMEI map
|
||||
# read the current device settings
|
||||
TOKEN='your-bootstrap-or-app-token'
|
||||
curl -H "X-App-Token: $TOKEN" http://localhost:18080/configs/imei_map
|
||||
curl -H "X-App-Token: $TOKEN" http://localhost:18080/devices
|
||||
|
||||
# replace it (JSON object only; keys must be 15-digit IMEIs)
|
||||
# upsert ONE device with its settings document (the frontend way)
|
||||
curl -X PUT -H "X-App-Token: $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"350123456789012": "Greenhouse sensor A", "490154203237518": "Water tank sensor B"}' \
|
||||
-d '{"device": "Greenhouse sensor A", "location": "Växthus 1", "battery_warning": 20}' \
|
||||
http://localhost:18080/devices/350123456789012
|
||||
|
||||
# bulk import: replace ALL device settings at once (old flat values still accepted)
|
||||
curl -X PUT -H "X-App-Token: $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"350123456789012": {"device": "Greenhouse sensor A", "location": "Växthus 1"}}' \
|
||||
http://localhost:18080/configs/imei_map
|
||||
```
|
||||
|
||||
- `app_tokens`: `{"tokens": {"app-name": "secret"}}` — secrets are stored as SHA-256 digests (never plaintext) and cannot be read back; `GET /configs/app_tokens` shows only the app names. After an update the API re-authenticates with the **new** tokens immediately.
|
||||
- `imei_map`: `{"<15-digit-IMEI>": "<device name>"}` — the listener polls the table every ~5 seconds; a temporarily unreachable database never breaks ingestion (the last good mapping stays in effect).
|
||||
- `app_tokens`: `"tokens": {"app-name": "secret"}` — secrets are stored as SHA-256 digests (never plaintext) and cannot be read back; listing endpoints show only the app names. After an update the API re-authenticates with the **new** tokens immediately. For single-token rotation prefer `PUT /tokens/{app}`.
|
||||
- `imei_map`: one settings document per IMEI; `"device"` is required, other keys are arbitrary JSON merged into message payloads at read time. The listener polls the table every ~5 seconds; a temporarily unreachable database never breaks ingestion (the last good mapping stays in effect).
|
||||
|
||||
**Read-time settings merge:** `GET /messages` joins `imei_map` and merges the device's *current* settings document into each item's `payload` (`COALESCE(im.meta, '{}'::jsonb) || m.payload` — the stored payload wins on duplicate keys). Historical messages therefore always reflect the *latest* device settings; the ingest-time `imei`/`device` snapshots in the stored payload and columns are unaffected.
|
||||
|
||||
#### Migrating an existing deployment from the JSON files
|
||||
|
||||
For a database that already has data (init scripts only run on an empty volume), apply the schema and migrate the JSON configs:
|
||||
For a database that already has data (init scripts only run on an empty volume), apply the schema and migrate the JSON configs. The schema script is idempotent and **migrates the old `imei_map(device TEXT)` layout in place**: every existing row is converted to `{"device": ...}` and the legacy column is dropped:
|
||||
|
||||
```bash
|
||||
docker compose exec db psql -U nodeledge -d nodeledge -f - < init/01_schema.sql
|
||||
@@ -157,7 +172,8 @@ Example response:
|
||||
"temperature": 21.5,
|
||||
"timestamp": "2026-08-31T09:59:58+00:00",
|
||||
"imei": "350123456789012",
|
||||
"device": "Greenhouse sensor A"
|
||||
"device": "Greenhouse sensor A",
|
||||
"location": "Växthus 1"
|
||||
},
|
||||
"payload_time": "2026-08-31T09:59:58+00:00"
|
||||
}
|
||||
|
||||
+269
-17
@@ -1,6 +1,14 @@
|
||||
"""FastAPI application exposing read access to the mqtt_messages table and
|
||||
management of the config tables (app_tokens, imei_map) in the database.
|
||||
|
||||
Device settings live in imei_map as one JSONB document per IMEI (the
|
||||
"device" key holds the device name). They are managed in bulk via
|
||||
PUT /configs/imei_map or — the way a management frontend should work —
|
||||
per device via the /devices/{imei} resource endpoints (single-row upsert,
|
||||
so concurrent edits of different devices never clobber each other).
|
||||
GET /messages merges each device's CURRENT settings document into the
|
||||
payload at read time; stored payload keys win on conflicts.
|
||||
|
||||
Authentication: app tokens stored as SHA-256 digests in the app_tokens
|
||||
table, passed either as the `X-App-Token` header or as an `app_token` query
|
||||
parameter. Digests are compared in constant time; invalid tokens yield 401.
|
||||
@@ -20,8 +28,9 @@ from typing import Any
|
||||
import psycopg
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request, status
|
||||
from psycopg import sql
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from listener.process import validate_imei_map
|
||||
from listener.process import normalize_imei, validate_imei_doc, validate_imei_map
|
||||
|
||||
logger = logging.getLogger("api")
|
||||
|
||||
@@ -156,24 +165,107 @@ class TokenStore:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def upsert(self, app_name: str, secret: str) -> None:
|
||||
"""Add or rotate ONE app token in place (single-row upsert).
|
||||
|
||||
Unlike replace() this never touches the other apps' rows, so a
|
||||
token-management UI cannot clobber concurrent edits. Verification
|
||||
reads the table on every request, so the change takes effect
|
||||
immediately.
|
||||
"""
|
||||
# autocommit: a single-statement write commits immediately (a plain
|
||||
# connection would roll back on close).
|
||||
conn = psycopg.connect(self._dsn, autocommit=True)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO app_tokens (app_name, token_hash)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (app_name) DO UPDATE
|
||||
SET token_hash = EXCLUDED.token_hash, updated_at = now()
|
||||
""",
|
||||
(app_name, hash_token(secret)),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete(self, app_name: str) -> bool:
|
||||
"""Remove one app token; returns False when the name is unknown."""
|
||||
conn = psycopg.connect(self._dsn, autocommit=True)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM app_tokens WHERE app_name = %s", (app_name,))
|
||||
return cur.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class ImeiMapStore:
|
||||
"""IMEI -> device name mapping backed by the imei_map table."""
|
||||
"""Device settings backed by the imei_map table: one JSONB document
|
||||
per IMEI whose "device" key holds the device name (other keys are
|
||||
arbitrary settings merged into message payloads at read time)."""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
self._dsn = dsn
|
||||
|
||||
def load(self) -> dict[str, str]:
|
||||
def load(self) -> dict[str, dict[str, Any]]:
|
||||
"""All settings documents as {imei: document}."""
|
||||
conn = psycopg.connect(self._dsn)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT imei, device FROM imei_map ORDER BY imei")
|
||||
cur.execute("SELECT imei, meta FROM imei_map ORDER BY imei")
|
||||
return {row[0]: row[1] for row in cur.fetchall()}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def replace(self, mapping: dict[str, str]) -> None:
|
||||
"""Replace the whole mapping (delete + insert) in one transaction."""
|
||||
def get(self, imei: str) -> dict[str, Any] | None:
|
||||
"""One settings document, or None when the IMEI is unknown."""
|
||||
conn = psycopg.connect(self._dsn)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT meta FROM imei_map WHERE imei = %s", (imei,))
|
||||
row = cur.fetchone()
|
||||
return row[0] if row is not None else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def upsert(self, imei: str, doc: dict[str, Any]) -> None:
|
||||
"""Insert or replace ONE device's settings (single-row upsert, so
|
||||
concurrent edits of different devices never clobber each other)."""
|
||||
# autocommit: a single-statement write commits immediately (a plain
|
||||
# connection would roll back on close).
|
||||
conn = psycopg.connect(self._dsn, autocommit=True)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO imei_map (imei, meta)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (imei) DO UPDATE
|
||||
SET meta = EXCLUDED.meta, updated_at = now()
|
||||
""",
|
||||
(imei, Jsonb(doc)),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete(self, imei: str) -> bool:
|
||||
"""Remove one device; returns False when the IMEI is unknown."""
|
||||
conn = psycopg.connect(self._dsn, autocommit=True)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM imei_map WHERE imei = %s", (imei,))
|
||||
return cur.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def replace(self, mapping: dict[str, dict[str, Any]]) -> None:
|
||||
"""Replace ALL devices (delete + insert) in one transaction.
|
||||
|
||||
Bulk import only: a management frontend should use the
|
||||
per-device upsert/delete so concurrent edits of different
|
||||
devices cannot clobber each other."""
|
||||
conn = psycopg.connect(self._dsn)
|
||||
try:
|
||||
with conn.transaction():
|
||||
@@ -181,8 +273,8 @@ class ImeiMapStore:
|
||||
cur.execute("DELETE FROM imei_map")
|
||||
if mapping:
|
||||
cur.executemany(
|
||||
"INSERT INTO imei_map (imei, device) VALUES (%s, %s)",
|
||||
list(mapping.items()),
|
||||
"INSERT INTO imei_map (imei, meta) VALUES (%s, %s)",
|
||||
[(imei, Jsonb(doc)) for imei, doc in mapping.items()],
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -207,6 +299,24 @@ def _validate_token_body(data: dict[str, Any], source: str) -> dict[str, str]:
|
||||
return dict(tokens)
|
||||
|
||||
|
||||
def _validate_single_token_body(data: dict[str, Any], source: str) -> str:
|
||||
"""Validate a {"token": secret} request body and return the secret.
|
||||
Raises ValueError with a descriptive message otherwise."""
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or not isinstance(data.get("token"), str)
|
||||
or not data["token"]
|
||||
):
|
||||
raise ValueError(f"{source}: expected JSON on the form {{\"token\": \"secret\"}}")
|
||||
return data["token"]
|
||||
|
||||
|
||||
def _device_repr(imei: str, doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""API representation of a device: its settings document plus the
|
||||
IMEI, so a GET response can be edited and PUT straight back."""
|
||||
return {"imei": imei, **doc}
|
||||
|
||||
|
||||
def _env(name: str, default: str | None = None) -> str:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
@@ -319,6 +429,138 @@ def create_app(
|
||||
raise _db_unavailable(exc) from exc
|
||||
return {"status": "updated", "config": name}
|
||||
|
||||
# --- Device resources: the per-IMEI API a management frontend uses.
|
||||
# Single-row upsert/delete means editing one device never clobbers a
|
||||
# concurrent edit of another (unlike the whole-document config PUT).
|
||||
# The listener picks up changes within ~5 seconds via its poll.
|
||||
|
||||
@app.get("/devices", tags=["devices"], dependencies=[Depends(authenticate)])
|
||||
def list_devices() -> list[dict[str, Any]]:
|
||||
try:
|
||||
return [_device_repr(imei, doc) for imei, doc in imei_store.load().items()]
|
||||
except psycopg.Error as exc:
|
||||
raise _db_unavailable(exc) from exc
|
||||
|
||||
@app.get("/devices/{imei}", tags=["devices"], dependencies=[Depends(authenticate)])
|
||||
def get_device(imei: str) -> dict[str, Any]:
|
||||
normalized = normalize_imei(imei)
|
||||
if normalized is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{imei!r} is not a 15-digit IMEI",
|
||||
)
|
||||
try:
|
||||
doc = imei_store.get(normalized)
|
||||
except psycopg.Error as exc:
|
||||
raise _db_unavailable(exc) from exc
|
||||
if doc is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"unknown device {normalized}",
|
||||
)
|
||||
return _device_repr(normalized, doc)
|
||||
|
||||
@app.put("/devices/{imei}", tags=["devices"], dependencies=[Depends(authenticate)])
|
||||
def set_device(imei: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = normalize_imei(imei)
|
||||
if normalized is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{imei!r} is not a 15-digit IMEI",
|
||||
)
|
||||
try:
|
||||
# A body "imei" key is tolerated when it matches the URL, so a
|
||||
# GET response can be edited and PUT straight back.
|
||||
doc = dict(body)
|
||||
claimed = doc.pop("imei", normalized)
|
||||
if claimed != normalized:
|
||||
raise ValueError(
|
||||
f"request body: imei {claimed!r} does not match the URL imei "
|
||||
f"{normalized!r}"
|
||||
)
|
||||
doc = validate_imei_doc(doc, "request body")
|
||||
imei_store.upsert(normalized, doc)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except psycopg.Error as exc:
|
||||
raise _db_unavailable(exc) from exc
|
||||
logger.info("device %s upserted: %d setting(s)", normalized, len(doc))
|
||||
return _device_repr(normalized, doc)
|
||||
|
||||
@app.delete("/devices/{imei}", tags=["devices"], dependencies=[Depends(authenticate)])
|
||||
def delete_device(imei: str) -> dict[str, Any]:
|
||||
normalized = normalize_imei(imei)
|
||||
if normalized is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{imei!r} is not a 15-digit IMEI",
|
||||
)
|
||||
try:
|
||||
deleted = imei_store.delete(normalized)
|
||||
except psycopg.Error as exc:
|
||||
raise _db_unavailable(exc) from exc
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"unknown device {normalized}",
|
||||
)
|
||||
logger.info("device %s deleted", normalized)
|
||||
return {"status": "deleted", "imei": normalized}
|
||||
|
||||
# --- Token resources: add/rotate/remove ONE app token without
|
||||
# touching the other apps' rows.
|
||||
|
||||
@app.get("/tokens", tags=["tokens"], dependencies=[Depends(authenticate)])
|
||||
def list_tokens() -> dict[str, Any]:
|
||||
try:
|
||||
# Secrets are stored as digests and not recoverable; expose
|
||||
# only the app names (same shape as GET /configs/app_tokens).
|
||||
return {"tokens": {name: None for name in token_store.names()}}
|
||||
except psycopg.Error as exc:
|
||||
raise _db_unavailable(exc) from exc
|
||||
|
||||
@app.put("/tokens/{app_name}", tags=["tokens"], dependencies=[Depends(authenticate)])
|
||||
def set_token(app_name: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
name = app_name.strip()
|
||||
try:
|
||||
if not name:
|
||||
raise ValueError("app name must be a non-empty string")
|
||||
secret = _validate_single_token_body(body, "request body")
|
||||
token_store.upsert(name, secret)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except psycopg.Error as exc:
|
||||
raise _db_unavailable(exc) from exc
|
||||
# The store reads the table on every request: effective immediately.
|
||||
logger.info("token %r upserted", name)
|
||||
return {"status": "updated", "app": name}
|
||||
|
||||
@app.delete("/tokens/{app_name}", tags=["tokens"], dependencies=[Depends(authenticate)])
|
||||
def delete_token(app_name: str) -> dict[str, Any]:
|
||||
name = app_name.strip()
|
||||
if not name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="app name must be a non-empty string",
|
||||
)
|
||||
try:
|
||||
deleted = token_store.delete(name)
|
||||
except psycopg.Error as exc:
|
||||
raise _db_unavailable(exc) from exc
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"unknown app token {name!r}",
|
||||
)
|
||||
logger.info("token %r deleted", name)
|
||||
return {"status": "deleted", "app": name}
|
||||
|
||||
@app.get("/health", tags=["meta"])
|
||||
def health() -> dict[str, Any]:
|
||||
return {"status": "ok"}
|
||||
@@ -394,25 +636,27 @@ def create_app(
|
||||
detail="'payload_from' must be earlier than or equal to 'payload_to'",
|
||||
)
|
||||
limit = min(limit, config.max_limit)
|
||||
# Conditions reference the aliased mqtt_messages table (m.) because
|
||||
# the query joins imei_map for the read-time settings merge.
|
||||
conditions: list[sql.Composable] = []
|
||||
params: list[Any] = []
|
||||
if imei:
|
||||
conditions.append(sql.SQL("imei = %s"))
|
||||
conditions.append(sql.SQL("m.imei = %s"))
|
||||
params.append(imei)
|
||||
if device:
|
||||
conditions.append(sql.SQL("device = %s"))
|
||||
conditions.append(sql.SQL("m.device = %s"))
|
||||
params.append(device)
|
||||
if from_time is not None:
|
||||
conditions.append(sql.SQL("received_at >= %s"))
|
||||
conditions.append(sql.SQL("m.received_at >= %s"))
|
||||
params.append(_as_utc(from_time))
|
||||
if to_time is not None:
|
||||
conditions.append(sql.SQL("received_at <= %s"))
|
||||
conditions.append(sql.SQL("m.received_at <= %s"))
|
||||
params.append(_as_utc(to_time))
|
||||
if payload_from_time is not None:
|
||||
conditions.append(sql.SQL("payload_time >= %s"))
|
||||
conditions.append(sql.SQL("m.payload_time >= %s"))
|
||||
params.append(_as_utc(payload_from_time))
|
||||
if payload_to_time is not None:
|
||||
conditions.append(sql.SQL("payload_time <= %s"))
|
||||
conditions.append(sql.SQL("m.payload_time <= %s"))
|
||||
params.append(_as_utc(payload_to_time))
|
||||
|
||||
where = sql.SQL("")
|
||||
@@ -422,12 +666,20 @@ def create_app(
|
||||
# Order by device timestamp when framing on it (NULLS LAST matches the
|
||||
# idx_mqtt_messages_ptime index); otherwise newest-first by insert id.
|
||||
if payload_from_time is not None or payload_to_time is not None:
|
||||
order_by = sql.SQL(" ORDER BY payload_time DESC NULLS LAST LIMIT %s OFFSET %s")
|
||||
order_by = sql.SQL(" ORDER BY m.payload_time DESC NULLS LAST LIMIT %s OFFSET %s")
|
||||
else:
|
||||
order_by = sql.SQL(" ORDER BY id DESC LIMIT %s OFFSET %s")
|
||||
order_by = sql.SQL(" ORDER BY m.id DESC LIMIT %s OFFSET %s")
|
||||
|
||||
# LEFT JOIN imei_map merges the device's CURRENT settings document
|
||||
# into the payload at read time, so dynamic settings (location,
|
||||
# thresholds, ...) are up to date for every stored message. jsonb
|
||||
# concatenation makes the RIGHT operand win on duplicate keys: the
|
||||
# stored payload (the device's own data plus the ingest-time
|
||||
# imei/device snapshot) takes precedence over the settings doc.
|
||||
query = sql.SQL(
|
||||
"SELECT id, received_at, topic, imei, device, payload, payload_time FROM mqtt_messages"
|
||||
"SELECT m.id, m.received_at, m.topic, m.imei, m.device, "
|
||||
"COALESCE(im.meta, '{}'::jsonb) || m.payload, m.payload_time "
|
||||
"FROM mqtt_messages m LEFT JOIN imei_map im ON im.imei = m.imei"
|
||||
) + where + order_by
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"350123456789012": "Greenhouse sensor A",
|
||||
"490154203237518": "Water tank sensor B",
|
||||
"111111111111111": "Newly added device"
|
||||
"350123456789012": {
|
||||
"device": "Greenhouse sensor A",
|
||||
"location": "Växthus 1"
|
||||
},
|
||||
"490154203237518": {
|
||||
"device": "Water tank sensor B"
|
||||
},
|
||||
"111111111111111": {
|
||||
"device": "Newly added device"
|
||||
}
|
||||
}
|
||||
|
||||
+30
-3
@@ -46,14 +46,41 @@ CREATE TABLE IF NOT EXISTS app_tokens (
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- IMEI -> device name mapping used by the listener at ingest time.
|
||||
-- Device settings: one JSONB document per IMEI. The "device" key holds
|
||||
-- the device name injected into payloads by the listener; every other
|
||||
-- key is arbitrary settings data that the API merges into the payload
|
||||
-- at read time (GET /messages). The listener polls this table every 5 s.
|
||||
CREATE TABLE IF NOT EXISTS imei_map (
|
||||
imei TEXT PRIMARY KEY CHECK (imei ~ '^[0-9]{15}$'),
|
||||
device TEXT NOT NULL CHECK (device <> ''),
|
||||
meta JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Migration for deployments created before the meta column existed
|
||||
-- (they stored a flat device TEXT column): backfill each meta document
|
||||
-- from it, then drop it. Idempotent — on fresh databases (and after a
|
||||
-- successful run) the DO block is a no-op.
|
||||
ALTER TABLE imei_map ADD COLUMN IF NOT EXISTS meta JSONB;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'imei_map'
|
||||
AND column_name = 'device'
|
||||
) THEN
|
||||
UPDATE imei_map
|
||||
SET meta = jsonb_build_object('device', device)
|
||||
WHERE meta IS NULL;
|
||||
ALTER TABLE imei_map DROP COLUMN device;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
ALTER TABLE imei_map ALTER COLUMN meta SET NOT NULL;
|
||||
|
||||
COMMENT ON TABLE app_tokens IS
|
||||
'API app tokens as SHA-256 hex digests; seeded from BOOTSTRAP_APP_TOKEN when empty.';
|
||||
COMMENT ON TABLE imei_map IS
|
||||
'IMEI -> device name mapping, polled by the listener every 5 seconds.';
|
||||
'Device settings (one JSONB document per IMEI), polled by the listener every 5 seconds; the API merges the current document into message payloads at read time.';
|
||||
COMMENT ON COLUMN imei_map.meta IS
|
||||
'Settings document; "device" (non-empty string) is the device name injected at ingest, other keys are merged into message payloads at read time.';
|
||||
+25
-10
@@ -3,8 +3,10 @@
|
||||
The network thread writes rows; writes happen only on that thread so a plain
|
||||
(no pool) blocking connection is used, with reconnection on failure.
|
||||
|
||||
The IMEI -> device mapping lives in the imei_map table and is polled by a
|
||||
separate daemon thread (ImeiMapReader) with its own connection.
|
||||
Device settings live in the imei_map table (one JSONB document per IMEI;
|
||||
the "device" key holds the device name). The ImeiMapReader daemon thread
|
||||
polls the table with its own connection and derives the IMEI -> device
|
||||
name mapping used by the ingest path from the documents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -84,12 +86,15 @@ class Inserter:
|
||||
|
||||
|
||||
class ImeiMapReader:
|
||||
"""Thread that polls the imei_map table for the IMEI -> device mapping.
|
||||
"""Thread that polls the imei_map table for device settings documents.
|
||||
|
||||
Runs its OWN persistent autocommit connection (never shared with
|
||||
Inserter, whose connection is funneled through the MQTT network thread).
|
||||
Reconnects on failure; a failed poll keeps the previously read mapping,
|
||||
mirroring the old broken-file-keeps-last-good-map semantics.
|
||||
Derives the {imei: device name} mapping used by the ingest path from
|
||||
the rows' meta documents (meta->>'device'; IMEIs without a usable
|
||||
device value fall back to the IMEI itself, matching the unmapped-IMEI
|
||||
behaviour). Runs its OWN persistent autocommit connection (never shared
|
||||
with Inserter, whose connection is funneled through the MQTT network
|
||||
thread). Reconnects on failure; a failed poll keeps the previously read
|
||||
mapping, mirroring the old broken-file-keeps-last-good-map semantics.
|
||||
"""
|
||||
|
||||
POLL_INTERVAL_SECONDS = 5.0
|
||||
@@ -111,14 +116,24 @@ class ImeiMapReader:
|
||||
return self._conn
|
||||
|
||||
def read(self) -> dict[str, str]:
|
||||
"""Read the mapping from the database (single SELECT).
|
||||
"""Read the {imei: device name} mapping from the database (single
|
||||
SELECT), deriving each device name from the row's settings
|
||||
document.
|
||||
|
||||
Raises psycopg.Error on connection/query failure; the caller keeps
|
||||
the last good mapping."""
|
||||
conn = self._connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT imei, device FROM imei_map")
|
||||
return dict(cur.fetchall())
|
||||
cur.execute("SELECT imei, meta FROM imei_map")
|
||||
mapping: dict[str, str] = {}
|
||||
for imei, meta in cur.fetchall():
|
||||
device = None
|
||||
if isinstance(meta, dict):
|
||||
value = meta.get("device")
|
||||
if isinstance(value, str) and value:
|
||||
device = value
|
||||
mapping[imei] = device if device is not None else imei
|
||||
return mapping
|
||||
|
||||
def _run(self) -> None:
|
||||
"""Poll loop; runs in its own daemon thread."""
|
||||
|
||||
+46
-11
@@ -6,6 +6,7 @@ import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
# An IMEI is a 15 digit decimal number. The topic segment must be exactly
|
||||
# that: 15 digits, no separators, no letters, no other characters.
|
||||
@@ -27,24 +28,58 @@ def normalize_imei(value: str) -> str | None:
|
||||
return value
|
||||
|
||||
|
||||
def validate_imei_map(data: object, source: str) -> dict[str, str]:
|
||||
"""Validate an IMEI -> device name mapping.
|
||||
def validate_imei_doc(doc: object, source: str) -> dict[str, Any]:
|
||||
"""Validate a single device settings document.
|
||||
|
||||
Must be a JSON object with 15-digit IMEI keys and non-empty string
|
||||
values. Raises ValueError with a descriptive message otherwise. Shared
|
||||
by the listener (file load) and the API (config write endpoint) so both
|
||||
enforce identical rules.
|
||||
Must be a JSON object whose "device" key is a non-empty string; every
|
||||
other key may hold an arbitrary JSON value (merged into message
|
||||
payloads by the API at read time). The "imei" key is reserved (the
|
||||
IMEI is the resource key / comes from the topic) and rejected.
|
||||
Raises ValueError with a descriptive message otherwise. Shared by the
|
||||
API's per-device and bulk config endpoints so all writers enforce
|
||||
identical rules.
|
||||
"""
|
||||
if not isinstance(doc, dict):
|
||||
raise ValueError(
|
||||
f"{source}: expected a JSON object with a \"device\" key "
|
||||
"and optional extra settings"
|
||||
)
|
||||
if "imei" in doc:
|
||||
raise ValueError(f"{source}: device documents must not contain an \"imei\" key")
|
||||
device = doc.get("device")
|
||||
if not isinstance(device, str) or not device:
|
||||
raise ValueError(f"{source}: \"device\" must be a non-empty string")
|
||||
return dict(doc)
|
||||
|
||||
|
||||
def validate_imei_map(data: object, source: str) -> dict[str, dict[str, Any]]:
|
||||
"""Validate an IMEI -> device settings mapping.
|
||||
|
||||
Must be a JSON object with 15-digit IMEI keys. Values are either a
|
||||
settings document (see validate_imei_doc) or — for backward
|
||||
compatibility with the flat legacy config — a plain device-name
|
||||
string, which is promoted to {"device": name}. Raises ValueError
|
||||
with a descriptive message otherwise. Shared by the API (config
|
||||
write endpoints) and the migration tool so all writers enforce
|
||||
identical rules.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"{source}: expected a JSON object mapping IMEI to device name")
|
||||
result: dict[str, str] = {}
|
||||
raise ValueError(
|
||||
f"{source}: expected a JSON object mapping IMEI to a device settings document"
|
||||
)
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for key, value in data.items():
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{source}: values must be non-empty strings (key {key!r})")
|
||||
normalized = key.strip()
|
||||
if normalize_imei(normalized) is None:
|
||||
raise ValueError(f"{source}: key {key!r} is not a 15-digit IMEI")
|
||||
result[normalized] = value
|
||||
if isinstance(value, str):
|
||||
if not value:
|
||||
raise ValueError(
|
||||
f"{source}: device name for {normalized!r} must be a non-empty string"
|
||||
)
|
||||
result[normalized] = {"device": value}
|
||||
else:
|
||||
result[normalized] = validate_imei_doc(value, source)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""One-time migration: app_tokens.json + imei_map.json -> database tables.
|
||||
|
||||
Reads the legacy JSON config files, inserts app tokens as SHA-256 digests
|
||||
into app_tokens and IMEI -> device mappings into imei_map, then optionally
|
||||
verifies the migration with a login round-trip. Run it once against an
|
||||
into app_tokens and device settings documents into imei_map (flat
|
||||
"imei": "device name" values are promoted to {"device": ...}), then
|
||||
optionally verifies the migration with a login round-trip. Run it once
|
||||
against an
|
||||
existing deployment after applying init/01_schema.sql (the script is
|
||||
idempotent: it truncates both tables first).
|
||||
|
||||
@@ -27,6 +29,7 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import psycopg # noqa: E402
|
||||
from psycopg.types.json import Jsonb # noqa: E402
|
||||
|
||||
from api.app import hash_token # noqa: E402
|
||||
from listener.process import validate_imei_map # noqa: E402
|
||||
@@ -72,8 +75,8 @@ def main() -> int:
|
||||
[(name, hash_token(secret)) for name, secret in tokens.items()],
|
||||
)
|
||||
cur.executemany(
|
||||
"INSERT INTO imei_map (imei, device) VALUES (%s, %s)",
|
||||
list(mapping.items()),
|
||||
"INSERT INTO imei_map (imei, meta) VALUES (%s, %s)",
|
||||
[(imei, Jsonb(doc)) for imei, doc in mapping.items()],
|
||||
)
|
||||
|
||||
print("migrated. verifying read-back...")
|
||||
|
||||
+80
-2
@@ -5,6 +5,8 @@ Checks:
|
||||
2. API rejects requests with a wrong token (401)
|
||||
3. API accepts a valid token and returns stored messages
|
||||
4. Time framing: a wide from/to window is accepted, a reversed one is rejected
|
||||
5. Device resources: a scratch device is upserted, read back and deleted
|
||||
6. Token resources: a scratch token authenticates and is then removed
|
||||
|
||||
Usage:
|
||||
python tools/smoke_test_api.py --url http://localhost:18080 --token <secret>
|
||||
@@ -22,8 +24,15 @@ import urllib.request
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def request(url: str, token: str | None) -> tuple[int, str]:
|
||||
req = urllib.request.Request(url)
|
||||
def request(
|
||||
url: str,
|
||||
token: str | None,
|
||||
method: str = "GET",
|
||||
data: bytes | None = None,
|
||||
) -> tuple[int, str]:
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if token is not None:
|
||||
req.add_header("X-App-Token", token)
|
||||
try:
|
||||
@@ -88,6 +97,75 @@ def main() -> int:
|
||||
else:
|
||||
failures.append(f"reversed from/to window -> expected 422, got {status}: {body}")
|
||||
|
||||
# 5. Device resources: upsert a scratch device, read it back, delete it
|
||||
scratch_imei = "999999999999999"
|
||||
scratch_doc = {"device": "Smoke test device", "location": "Växthus 1"}
|
||||
status, body = request(
|
||||
f"{args.url}/devices/{scratch_imei}",
|
||||
token=args.token,
|
||||
method="PUT",
|
||||
data=json.dumps(scratch_doc).encode(),
|
||||
)
|
||||
if status == 200 and json.loads(body) == {"imei": scratch_imei, **scratch_doc}:
|
||||
print("PASS: device upsert returned the saved document")
|
||||
else:
|
||||
failures.append(f"device upsert -> expected 200, got {status}: {body}")
|
||||
|
||||
status, body = request(f"{args.url}/devices/{scratch_imei}", token=args.token)
|
||||
if status == 200 and json.loads(body) == {"imei": scratch_imei, **scratch_doc}:
|
||||
print("PASS: device read-back matches")
|
||||
else:
|
||||
failures.append(f"device read-back -> expected 200, got {status}: {body}")
|
||||
|
||||
status, body = request(
|
||||
f"{args.url}/devices/{scratch_imei}", token=args.token, method="DELETE"
|
||||
)
|
||||
if status == 200:
|
||||
print("PASS: device deleted")
|
||||
else:
|
||||
failures.append(f"device delete -> expected 200, got {status}: {body}")
|
||||
|
||||
status, body = request(
|
||||
f"{args.url}/devices/{scratch_imei}", token=args.token, method="DELETE"
|
||||
)
|
||||
if status == 404:
|
||||
print("PASS: deleting an unknown device returns 404")
|
||||
else:
|
||||
failures.append(f"second device delete -> expected 404, got {status}: {body}")
|
||||
|
||||
# 6. Token resources: add a scratch token, authenticate with it, remove it
|
||||
scratch_secret = "smoke-test-secret-do-not-use"
|
||||
status, body = request(
|
||||
f"{args.url}/tokens/smoke-test",
|
||||
token=args.token,
|
||||
method="PUT",
|
||||
data=json.dumps({"token": scratch_secret}).encode(),
|
||||
)
|
||||
if status == 200:
|
||||
print("PASS: token upsert")
|
||||
else:
|
||||
failures.append(f"token upsert -> expected 200, got {status}: {body}")
|
||||
|
||||
status, body = request(f"{args.url}/messages?limit=1", token=scratch_secret)
|
||||
if status == 200:
|
||||
print("PASS: the scratch token authenticates")
|
||||
else:
|
||||
failures.append(f"scratch token auth -> expected 200, got {status}: {body}")
|
||||
|
||||
status, body = request(
|
||||
f"{args.url}/tokens/smoke-test", token=args.token, method="DELETE"
|
||||
)
|
||||
if status == 200:
|
||||
print("PASS: token deleted")
|
||||
else:
|
||||
failures.append(f"token delete -> expected 200, got {status}: {body}")
|
||||
|
||||
status, body = request(f"{args.url}/messages?limit=1", token=scratch_secret)
|
||||
if status == 401:
|
||||
print("PASS: the removed token no longer authenticates")
|
||||
else:
|
||||
failures.append(f"removed token auth -> expected 401, got {status}: {body}")
|
||||
|
||||
if failures:
|
||||
print("\nFAILURES:")
|
||||
for failure in failures:
|
||||
|
||||
+266
-24
@@ -23,9 +23,11 @@ from listener.process import ( # noqa: E402
|
||||
InjectionResult,
|
||||
parse_payload_time,
|
||||
process_message,
|
||||
validate_imei_doc,
|
||||
validate_imei_map,
|
||||
)
|
||||
from datetime import datetime, timezone # noqa: E402
|
||||
from typing import Any # noqa: E402
|
||||
|
||||
IMEI_MAP = {
|
||||
"350123456789012": "Greenhouse sensor A",
|
||||
@@ -265,23 +267,58 @@ class TestPayloadTimeExtraction:
|
||||
assert result[0].payload_time is None
|
||||
|
||||
|
||||
class TestValidateImeiDoc:
|
||||
"""validate_imei_doc is the shared schema check for a single device
|
||||
settings document (per-device API writes)."""
|
||||
|
||||
def test_accepts_document_with_extra_keys(self) -> None:
|
||||
doc = {"device": "Device X", "location": "Växthus 1", "thresholds": {"high": 20}}
|
||||
assert validate_imei_doc(doc, "test") == doc
|
||||
|
||||
def test_rejects_non_object(self) -> None:
|
||||
with pytest.raises(ValueError, match="expected a JSON object"):
|
||||
validate_imei_doc("Device X", "test")
|
||||
|
||||
def test_rejects_missing_or_bad_device(self) -> None:
|
||||
with pytest.raises(ValueError, match="\\\"device\\\" must be a non-empty string"):
|
||||
validate_imei_doc({"location": "Växthus 1"}, "test")
|
||||
with pytest.raises(ValueError, match="\\\"device\\\" must be a non-empty string"):
|
||||
validate_imei_doc({"device": 42}, "test")
|
||||
|
||||
def test_rejects_imei_key(self) -> None:
|
||||
# the IMEI is the resource key / comes from the topic, never the doc
|
||||
with pytest.raises(ValueError, match="must not contain an"):
|
||||
validate_imei_doc({"device": "X", "imei": "350123456789012"}, "test")
|
||||
|
||||
|
||||
class TestValidateImeiMap:
|
||||
"""validate_imei_map is the shared schema check for the imei_map table
|
||||
writes (API request bodies and the migration tool)."""
|
||||
writes (API config bodies and the migration tool)."""
|
||||
|
||||
def test_accepts_valid_map(self) -> None:
|
||||
assert validate_imei_map(
|
||||
{"350123456789012": "Device X"}, "test"
|
||||
) == {"350123456789012": "Device X"}
|
||||
def test_accepts_document_values(self) -> None:
|
||||
doc = {"device": "Device X", "location": "Växthus 1"}
|
||||
assert validate_imei_map({"350123456789012": doc}, "test") == {
|
||||
"350123456789012": doc
|
||||
}
|
||||
|
||||
def test_promotes_legacy_flat_device_names(self) -> None:
|
||||
# the old {"imei": "name"} config form is accepted and promoted
|
||||
assert validate_imei_map({"350123456789012": "Device X"}, "test") == {
|
||||
"350123456789012": {"device": "Device X"}
|
||||
}
|
||||
|
||||
def test_rejects_non_object(self) -> None:
|
||||
with pytest.raises(ValueError, match="expected a JSON object"):
|
||||
validate_imei_map([], "test")
|
||||
|
||||
def test_rejects_non_string_values(self) -> None:
|
||||
with pytest.raises(ValueError, match="non-empty strings"):
|
||||
def test_rejects_bad_documents(self) -> None:
|
||||
with pytest.raises(ValueError, match="expected a JSON object"):
|
||||
validate_imei_map({"350123456789012": 42}, "test")
|
||||
|
||||
def test_rejects_empty_legacy_name(self) -> None:
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
validate_imei_map({"350123456789012": ""}, "test")
|
||||
|
||||
def test_rejects_non_imei_key(self) -> None:
|
||||
# Old dev EUI-style key: no longer accepted
|
||||
with pytest.raises(ValueError, match="15-digit IMEI"):
|
||||
@@ -325,25 +362,51 @@ class FakeTokenStore(TokenStore):
|
||||
def names(self) -> list[str]:
|
||||
return sorted(self._tokens)
|
||||
|
||||
def upsert(self, app_name: str, secret: str) -> None:
|
||||
self._tokens[app_name] = secret
|
||||
|
||||
def delete(self, app_name: str) -> bool:
|
||||
if app_name in self._tokens:
|
||||
del self._tokens[app_name]
|
||||
return True
|
||||
return False
|
||||
|
||||
def replace(self, tokens: dict[str, str]) -> None:
|
||||
self.replaced_with = dict(tokens)
|
||||
self._tokens = dict(tokens)
|
||||
|
||||
|
||||
class FakeImeiMapStore(ImeiMapStore):
|
||||
"""In-memory imei map store: no database."""
|
||||
"""In-memory device settings store: no database."""
|
||||
|
||||
def __init__(self, mapping: dict[str, str]) -> None:
|
||||
def __init__(self, mapping: dict[str, dict[str, Any]]) -> None:
|
||||
super().__init__("postgresql://unused")
|
||||
self._mapping = dict(mapping)
|
||||
self.replaced_with: dict[str, str] | None = None
|
||||
self._mapping = {imei: dict(doc) for imei, doc in mapping.items()}
|
||||
self.replaced_with: dict[str, dict[str, Any]] | None = None
|
||||
self.upserted: tuple[str, dict[str, Any]] | None = None
|
||||
self.deleted: str | None = None
|
||||
|
||||
def load(self) -> dict[str, str]:
|
||||
return dict(self._mapping)
|
||||
def load(self) -> dict[str, dict[str, Any]]:
|
||||
return {imei: dict(doc) for imei, doc in self._mapping.items()}
|
||||
|
||||
def replace(self, mapping: dict[str, str]) -> None:
|
||||
self.replaced_with = dict(mapping)
|
||||
self._mapping = dict(mapping)
|
||||
def get(self, imei: str) -> dict[str, Any] | None:
|
||||
doc = self._mapping.get(imei)
|
||||
return dict(doc) if doc is not None else None
|
||||
|
||||
def upsert(self, imei: str, doc: dict[str, Any]) -> None:
|
||||
self.upserted = (imei, dict(doc))
|
||||
self._mapping[imei] = dict(doc)
|
||||
|
||||
def delete(self, imei: str) -> bool:
|
||||
self.deleted = imei
|
||||
if imei in self._mapping:
|
||||
del self._mapping[imei]
|
||||
return True
|
||||
return False
|
||||
|
||||
def replace(self, mapping: dict[str, dict[str, Any]]) -> None:
|
||||
self.replaced_with = {imei: dict(doc) for imei, doc in mapping.items()}
|
||||
self._mapping = {imei: dict(doc) for imei, doc in mapping.items()}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -372,7 +435,9 @@ def app_module(monkeypatch):
|
||||
def api(app_module):
|
||||
"""ASGI app wired to in-memory fakes; yields (app, token_store, imei_store)."""
|
||||
tokens = FakeTokenStore({"test-app": "secret-abc"})
|
||||
imeis = FakeImeiMapStore({"350123456789012": "Greenhouse sensor A"})
|
||||
imeis = FakeImeiMapStore(
|
||||
{"350123456789012": {"device": "Greenhouse sensor A"}}
|
||||
)
|
||||
app = app_module.create_app(token_store=tokens, imei_store=imeis)
|
||||
return app, tokens, imeis
|
||||
|
||||
@@ -471,8 +536,8 @@ class TestConfigEndpoints:
|
||||
client, _, imeis = client_and_stores
|
||||
r = client.get("/configs/imei_map", headers=self.HDRS)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"350123456789012": "Greenhouse sensor A"}
|
||||
assert imeis.load() == {"350123456789012": "Greenhouse sensor A"}
|
||||
assert r.json() == {"350123456789012": {"device": "Greenhouse sensor A"}}
|
||||
assert imeis.load() == {"350123456789012": {"device": "Greenhouse sensor A"}}
|
||||
|
||||
def test_get_app_tokens_masks_secrets(self, client_and_stores) -> None:
|
||||
# secrets are stored as digests and not recoverable; only names show
|
||||
@@ -493,12 +558,18 @@ class TestConfigEndpoints:
|
||||
|
||||
def test_put_valid_imei_map(self, client_and_stores) -> None:
|
||||
client, _, imeis = client_and_stores
|
||||
body = {"350123456789012": "Greenhouse sensor A", "490154203237518": "Water tank sensor B"}
|
||||
body = {
|
||||
"350123456789012": {"device": "Greenhouse sensor A", "location": "Växthus 1"},
|
||||
"490154203237518": "Water tank sensor B", # legacy flat form is promoted
|
||||
}
|
||||
r = client.put("/configs/imei_map", json=body, headers=self.HDRS)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"status": "updated", "config": "imei_map"}
|
||||
# the store (and thereby the table) holds the new content
|
||||
assert imeis.replaced_with == body
|
||||
# the store (and thereby the table) holds normalized documents
|
||||
assert imeis.replaced_with == {
|
||||
"350123456789012": {"device": "Greenhouse sensor A", "location": "Växthus 1"},
|
||||
"490154203237518": {"device": "Water tank sensor B"},
|
||||
}
|
||||
|
||||
def test_post_is_alias_for_put(self, client_and_stores) -> None:
|
||||
client, _, _ = client_and_stores
|
||||
@@ -512,7 +583,8 @@ class TestConfigEndpoints:
|
||||
assert r.status_code == 422
|
||||
assert "15-digit IMEI" in r.json()["detail"]
|
||||
|
||||
def test_put_rejects_non_string_values(self, client_and_stores) -> None:
|
||||
def test_put_rejects_bad_documents(self, client_and_stores) -> None:
|
||||
# a value that is neither a settings document nor a device-name string
|
||||
client, _, _ = client_and_stores
|
||||
r = client.put("/configs/imei_map", json={"350123456789012": 42}, headers=self.HDRS)
|
||||
assert r.status_code == 422
|
||||
@@ -548,4 +620,174 @@ class TestConfigEndpoints:
|
||||
# the store verifies against the replaced set immediately
|
||||
assert tokens.verify("fresh-secret")
|
||||
assert tokens.verify("secret-abc")
|
||||
assert not tokens.verify("nope")
|
||||
assert not tokens.verify("nope")
|
||||
|
||||
|
||||
class TestDeviceEndpoints:
|
||||
"""GET/PUT/DELETE /devices/{imei} — the per-device resource API a
|
||||
management frontend uses (single-row upsert, so editing one device
|
||||
never clobbers another)."""
|
||||
|
||||
HDRS = {"X-App-Token": "secret-abc"}
|
||||
|
||||
@pytest.fixture()
|
||||
def client_and_store(self, api):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _, imeis = api
|
||||
return TestClient(app), imeis
|
||||
|
||||
def test_list_devices(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.get("/devices", headers=self.HDRS)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == [
|
||||
{"imei": "350123456789012", "device": "Greenhouse sensor A"}
|
||||
]
|
||||
|
||||
def test_get_device(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.get("/devices/350123456789012", headers=self.HDRS)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"imei": "350123456789012", "device": "Greenhouse sensor A"}
|
||||
|
||||
def test_get_unknown_device_404(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.get("/devices/356938035643809", headers=self.HDRS)
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_get_rejects_non_imei(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.get("/devices/not-an-imei", headers=self.HDRS)
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_device_endpoints_require_token(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
assert client.get("/devices").status_code == 401
|
||||
assert client.put("/devices/350123456789012", json={}).status_code == 401
|
||||
assert client.delete("/devices/350123456789012").status_code == 401
|
||||
|
||||
def test_put_upserts_new_device(self, client_and_store) -> None:
|
||||
client, imeis = client_and_store
|
||||
doc = {"device": "Pump X", "location": "Växthus 1", "enabled": True}
|
||||
r = client.put("/devices/356938035643809", json=doc, headers=self.HDRS)
|
||||
assert r.status_code == 200
|
||||
# the response is the saved document (+ imei), PUT-able straight back
|
||||
assert r.json() == {"imei": "356938035643809", **doc}
|
||||
assert imeis.get("356938035643809") == doc
|
||||
|
||||
def test_put_updates_existing_device_in_place(self, client_and_store) -> None:
|
||||
# a single-device save must not wipe the other devices' rows
|
||||
client, imeis = client_and_store
|
||||
r = client.put(
|
||||
"/devices/350123456789012",
|
||||
json={"device": "Renamed"},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert imeis.get("350123456789012") == {"device": "Renamed"}
|
||||
# untouched: no whole-document replace() happened
|
||||
assert imeis.replaced_with is None
|
||||
|
||||
def test_put_tolerates_matching_imei_key(self, client_and_store) -> None:
|
||||
# a GET response can be edited and PUT straight back
|
||||
client, _ = client_and_store
|
||||
r = client.put(
|
||||
"/devices/356938035643809",
|
||||
json={"imei": "356938035643809", "device": "Pump X"},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"imei": "356938035643809", "device": "Pump X"}
|
||||
|
||||
def test_put_rejects_mismatched_imei_key(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.put(
|
||||
"/devices/356938035643809",
|
||||
json={"imei": "350123456789012", "device": "Pump X"},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert "does not match" in r.json()["detail"]
|
||||
|
||||
def test_put_rejects_missing_device_key(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.put(
|
||||
"/devices/356938035643809", json={"location": "X"}, headers=self.HDRS
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_put_rejects_non_imei_url(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.put("/devices/123", json={"device": "X"}, headers=self.HDRS)
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_delete_device(self, client_and_store) -> None:
|
||||
client, imeis = client_and_store
|
||||
r = client.delete("/devices/350123456789012", headers=self.HDRS)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"status": "deleted", "imei": "350123456789012"}
|
||||
assert imeis.get("350123456789012") is None
|
||||
|
||||
def test_delete_unknown_device_404(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.delete("/devices/356938035643809", headers=self.HDRS)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestTokenEndpoints:
|
||||
"""PUT/DELETE /tokens/{app} — add/rotate/remove ONE token in place."""
|
||||
|
||||
HDRS = {"X-App-Token": "secret-abc"}
|
||||
|
||||
@pytest.fixture()
|
||||
def client_and_store(self, api):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, tokens, _ = api
|
||||
return TestClient(app), tokens
|
||||
|
||||
def test_list_tokens_masks_secrets(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.get("/tokens", headers=self.HDRS)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"tokens": {"test-app": None}}
|
||||
|
||||
def test_put_adds_token(self, client_and_store) -> None:
|
||||
client, tokens = client_and_store
|
||||
r = client.put("/tokens/frontend", json={"token": "ui-secret"}, headers=self.HDRS)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"status": "updated", "app": "frontend"}
|
||||
assert tokens.verify("ui-secret")
|
||||
|
||||
def test_put_rotates_token(self, client_and_store) -> None:
|
||||
client, tokens = client_and_store
|
||||
r = client.put("/tokens/test-app", json={"token": "rotated"}, headers=self.HDRS)
|
||||
assert r.status_code == 200
|
||||
assert tokens.verify("rotated")
|
||||
assert not tokens.verify("secret-abc")
|
||||
|
||||
def test_put_rejects_bad_body(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.put("/tokens/frontend", json={"foo": "bar"}, headers=self.HDRS)
|
||||
assert r.status_code == 422
|
||||
r = client.put("/tokens/frontend", json={"token": ""}, headers=self.HDRS)
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_delete_token(self, client_and_store) -> None:
|
||||
client, tokens = client_and_store
|
||||
r = client.delete("/tokens/test-app", headers=self.HDRS)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"status": "deleted", "app": "test-app"}
|
||||
assert not tokens.verify("secret-abc")
|
||||
|
||||
def test_delete_unknown_token_404(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.delete("/tokens/does-not-exist", headers=self.HDRS)
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_token_endpoints_require_token(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
assert client.get("/tokens").status_code == 401
|
||||
assert client.put("/tokens/x", json={"token": "y"}).status_code == 401
|
||||
assert client.delete("/tokens/x").status_code == 401
|
||||
Reference in New Issue
Block a user