diff --git a/README.md b/README.md index ada846d..ffbf2fb 100644 --- a/README.md +++ b/README.md @@ -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>": ""}` — 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" } diff --git a/api/app.py b/api/app.py index 35368d9..4e2d5a8 100644 --- a/api/app.py +++ b/api/app.py @@ -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: diff --git a/configs/listener/imei_map.json b/configs/listener/imei_map.json index 25f705f..85294b0 100644 --- a/configs/listener/imei_map.json +++ b/configs/listener/imei_map.json @@ -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" + } } diff --git a/init/01_schema.sql b/init/01_schema.sql index 24cac38..a52ccdf 100644 --- a/init/01_schema.sql +++ b/init/01_schema.sql @@ -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.'; \ No newline at end of file + '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.'; \ No newline at end of file diff --git a/listener/db.py b/listener/db.py index bcb8d8f..83c4afe 100644 --- a/listener/db.py +++ b/listener/db.py @@ -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.""" diff --git a/listener/process.py b/listener/process.py index 2d9c6a3..f675504 100644 --- a/listener/process.py +++ b/listener/process.py @@ -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 diff --git a/tools/migrate_configs_to_db.py b/tools/migrate_configs_to_db.py index 0c65cea..db4ff71 100644 --- a/tools/migrate_configs_to_db.py +++ b/tools/migrate_configs_to_db.py @@ -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...") diff --git a/tools/smoke_test_api.py b/tools/smoke_test_api.py index 68703f5..5bf6954 100644 --- a/tools/smoke_test_api.py +++ b/tools/smoke_test_api.py @@ -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 @@ -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: diff --git a/tools/test_listener.py b/tools/test_listener.py index f9fc748..eb28d15 100644 --- a/tools/test_listener.py +++ b/tools/test_listener.py @@ -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") \ No newline at end of file + 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 \ No newline at end of file