diff --git a/AGENTS.md b/AGENTS.md index afb1756..df4e315 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ Filer: | `index.html` | hele sida, inline CSS/JS, ingen byggesteg | | `config.example.toml` | kommentert mal; kopier til `config.toml` | | `docs/crew-integration.md` | kontrakten mot crew-panelet | -| `tests/test_auth.py` + `test_schedule.py` | `uv run pytest` – 80 tester | +| `tests/` | `uv run pytest` – 110 tester: auth, planlegging og HTTP-laget | | `Dockerfile` + `compose.yaml` | **drift** – appen kjøres i Docker | | `.env.example` | bruker-ID, portbinding og hemmeligheter | | `compose.test.yaml` | testoppsett med dummy-IdP, se under | @@ -254,6 +254,12 @@ satt i gang. - `/api/announce` legger meldingen i én FIFO-kø; en bakgrunnstråd spiller én om gangen med `pw-play`. `/api/stop` dreper gjeldende avspilling, `/api/clear` tømmer køen. +- En melding går `queued → preparing → playing → done | stopped | failed`. + `preparing` er syntesen – de første sekundene av hver edge-tts-melding, der + det er stille. Stopp virker også der: `Player.stop()` setter statusen, og + `run_once()` sjekker den før den starter avspilling. En planlagt sending + følger med helt i mål via `on_finished`, så planen viser `sent`, `failed` + eller `stopped` – det som faktisk skjedde – ikke bare at den ble lagt i kø. - `/api/status` krever `view`. Den var åpen før, og lekket både meldingstekster og IP-ene til dem som hadde sendt noe. IP vises nå bare for den som har `clear`; alle andre ser navnet. @@ -301,9 +307,14 @@ Vil Kent ha enda bedre lyd: ElevenLabs (nøkkel, best norsk) eller OpenAI - `pkill -f announcer.py` fra et skall som selv har `announcer.py` i kommandolinja dreper skallet. Gjelder utvikling med `uv run`; i drift er det `docker compose`. -- `player = Player()` står på modulnivå, så avspillingstråden starter ved - `import announcer` – også i `install.sh`, som importerer modulen for å laste - ned stemmer. Harmløst, men greit å vite. +- Trådene (`player.start()`, `start_scheduler()`) startes i `main()`, ikke + ved import. Det er det som gjør at `tests/test_routes.py` kan kjøre rutene + med en test_client og drive køen ett steg om gangen med + `player.run_once()`. Starter du dem ved import igjen, mister du den + muligheten. +- **Én prosess.** `main()` kjører waitress med mange tråder, ikke gunicorn + med flere workere: `Player` og planleggeren er singletons i prosessen, og + flere workere ville gitt flere køer som snakker i munnen på hverandre. - **Oppgradering:** `schedule` er en ny handling. Har et oppsett et eksplisitt `[roles]` i `config.toml`, får `crew` den *ikke* automatisk – eksplisitt konfigurasjon overstyrer standardverdiene, og da blir svaret 403 «Rollen din diff --git a/announcer.py b/announcer.py index 66abcc1..0fc2017 100644 --- a/announcer.py +++ b/announcer.py @@ -298,13 +298,19 @@ def synthesize(text: str, voice_name: str, speed: float, chime: str) -> Path: pcm += chunk.audio_int16_bytes pcm += b"\x00\x00" * int(rate * 0.3) - tmp = path.with_suffix(".tmp") + # Unik tmp-fil per kall. Med et fast navn ville to som forhåndslytter + # samme tekst samtidig skrevet til samme fil, og den ene rename feilet. + # Taper den ene kappløpet om selve path, er innholdet uansett likt. + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".tmp", dir=CACHE_DIR, delete=False) as fh: + tmp = Path(fh.name) with wave.open(str(tmp), "wb") as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(rate) w.writeframes(bytes(pcm)) - tmp.rename(path) + os.replace(tmp, path) return path @@ -325,8 +331,14 @@ class Item: subject: str = "" # stabil id, f.eks. "jwt:kent" method: str = "none" created: float = field(default_factory=time.time) - status: str = "queued" # queued | playing | done | stopped | failed + # queued -> preparing (syntese) -> playing -> done | stopped | failed + status: str = "queued" error: str = "" + schedule_id: str = "" # satt når meldingen kom fra planleggeren + + @property + def active(self) -> bool: + return self.status in ("preparing", "playing") def public(self, *, show_ip: bool = False) -> dict: return { @@ -360,17 +372,32 @@ def player_cmd(path: Path) -> list[str]: class Player: - def __init__(self) -> None: + """Én FIFO-kø, én avspilling om gangen. + + Tråden startes ikke her, men i start(), som main() kaller. Da kan rutene + testes med en test_client uten at noe begynner å spille, og run_once() + lar en test kjøre køen ett steg om gangen. + """ + + def __init__(self, on_finished=None) -> None: # noqa: ANN001 self.q: queue.Queue[Item] = queue.Queue() self.history: list[Item] = [] self.current: Item | None = None self.proc: subprocess.Popen | None = None self.lock = threading.Lock() self.seq = 0 - threading.Thread(target=self._loop, daemon=True, name="player").start() + # Kalles med hvert ferdige Item (done/stopped/failed). Det er slik + # planleggeren får vite hvordan det gikk, uten at Player vet om den. + self.on_finished = on_finished + self._thread: threading.Thread | None = None + + def start(self) -> None: + if self._thread is None: + self._thread = threading.Thread(target=self._loop, daemon=True, name="player") + self._thread.start() def enqueue(self, text: str, voice: str, speed: float, chime: str, sender: str, - who: auth.Principal | None = None) -> Item: + who: auth.Principal | None = None, schedule_id: str = "") -> Item: with self.lock: self.seq += 1 item = Item( @@ -378,6 +405,7 @@ class Player: user=who.name if who else "", subject=who.subject if who else "", method=who.method if who else "none", + schedule_id=schedule_id, ) self.history.append(item) del self.history[:-50] @@ -385,11 +413,18 @@ class Player: return item def stop(self) -> None: + """Stopper det som spilles – eller det som fortsatt lages. + + Under syntesen finnes det ingen prosess å drepe ennå; det er de første + sekundene av hver edge-tts-melding. Før var Stopp uten virkning da, + og meldingen gikk ut etterpå. Nå settes statusen, og _loop sjekker + den før den starter avspilling. + """ with self.lock: + if self.current and self.current.active: + self.current.status = "stopped" if self.proc and self.proc.poll() is None: self.proc.terminate() - if self.current: - self.current.status = "stopped" def clear(self) -> None: while True: @@ -406,43 +441,56 @@ class Player: "current": self.current.public(show_ip=show_ip) if self.current else None, "queued": [i.public(show_ip=show_ip) for i in self.history if i.status == "queued"], "history": [i.public(show_ip=show_ip) for i in reversed(self.history) - if i.status not in ("queued", "playing")][:15], + if not i.active and i.status != "queued"][:15], } def _loop(self) -> None: while True: - item = self.q.get() - if item.status != "queued": - continue - item.status = "playing" + self.run_once() + + def run_once(self, timeout: float | None = None) -> Item | None: + """Tar neste melding fra køen og spiller den ferdig. Returnerer den.""" + try: + item = self.q.get(timeout=timeout) + except queue.Empty: + return None + if item.status != "queued": + return item + item.status = "preparing" + with self.lock: + self.current = item + try: + path = synthesize(item.text, item.voice, item.speed, item.chime) with self.lock: - self.current = item - try: - path = synthesize(item.text, item.voice, item.speed, item.chime) - cmd = player_cmd(path) - with self.lock: - self.proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) - _, err = self.proc.communicate() - if item.status == "playing": - if self.proc.returncode == 0: - item.status = "done" - else: - item.status = "failed" - item.error = err.decode(errors="replace").strip()[-200:] - except Exception as e: # noqa: BLE001 - log.exception("avspilling feilet") - item.status = "failed" - item.error = str(e) - finally: - with self.lock: - self.current = None - self.proc = None - audit("played", id=item.id, status=item.status, error=item.error, - subject=item.subject, name=item.user, method=item.method, - ip=item.sender, text=item.text, voice=item.voice) - - -player = Player() + if item.status != "preparing": + return item # stoppet mens lyden ble laget + item.status = "playing" + self.proc = subprocess.Popen(player_cmd(path), stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE) + _, err = self.proc.communicate() + if item.status == "playing": + if self.proc.returncode == 0: + item.status = "done" + else: + item.status = "failed" + item.error = err.decode(errors="replace").strip()[-200:] + except Exception as e: # noqa: BLE001 + log.exception("avspilling feilet") + item.status = "failed" + item.error = str(e) + finally: + with self.lock: + self.current = None + self.proc = None + audit("played", id=item.id, status=item.status, error=item.error, + subject=item.subject, name=item.user, method=item.method, + ip=item.sender, text=item.text, voice=item.voice) + if self.on_finished: + try: + self.on_finished(item) + except Exception: # noqa: BLE001 + log.exception("on_finished feilet") + return item # -------------------------------------------------------------------------- @@ -458,32 +506,55 @@ schedule = sched.Schedule( TICK = max(5.0, float(CFG.get("schedule", "tick_seconds", 20))) -def _scheduler() -> None: - """Flytter forfalte meldinger inn i den vanlige køen. +def _scheduled_finished(item: Item) -> None: + """Players on_finished: lar planen følge meldingen helt i mål. - Egen tråd, og den gjør med vilje ingenting annet: avspilling, syntese og - feilhåndtering er Players jobb, slik at en planlagt melding oppfører seg - nøyaktig som en sendt for hånd når den først er i kø. + Før sto en planlagt sending som «sendt» i det den ble lagt i kø, og ble + stående slik selv om avspillingen feilet. Nå ender den som sent, failed + eller stopped – det som faktisk skjedde. """ + if not item.schedule_id: + return + status = {"done": "sent", "failed": "failed", "stopped": "stopped"}.get(item.status) + if status: + schedule.mark(item.schedule_id, status, item.error or f"kø #{item.id}") + + +player = Player(on_finished=_scheduled_finished) + + +def scheduler_tick() -> None: + """Ett steg: flytter forfalte meldinger inn i den vanlige køen. + + Gjør med vilje ingenting annet. Avspilling, syntese og feilhåndtering er + Players jobb, slik at en planlagt melding oppfører seg nøyaktig som en + sendt for hånd når den først er i kø. Egen funksjon, så tester kan + kalle den uten tråd. + """ + send, missed = schedule.due() + for item in missed: + schedule.mark(item.id, "missed", "maskina var ikke i gang i tide") + log.warning("planlagt #%s gikk glipp av (%s)", item.id, item.text[:40]) + audit("missed", id=item.id, subject=item.subject, name=item.user, + method=item.method, text=item.text, at=item.at) + for item in send: + queued = player.enqueue( + item.text, item.voice, item.speed, item.chime, + sender="planlagt", + who=auth.Principal(item.subject, item.user, (), item.method, frozenset()), + schedule_id=item.id, + ) + schedule.mark(item.id, "queued", f"kø #{queued.id}") + log.info("planlagt #%s lagt i kø som #%d", item.id, queued.id) + audit("scheduled_fire", id=item.id, queue_id=queued.id, + subject=item.subject, name=item.user, method=item.method, + text=item.text, voice=item.voice, at=item.at) + + +def _scheduler_loop() -> None: while True: try: - send, missed = schedule.due() - for item in missed: - schedule.mark(item.id, "missed", "maskina var ikke i gang i tide") - log.warning("planlagt #%s gikk glipp av (%s)", item.id, item.text[:40]) - audit("missed", id=item.id, subject=item.subject, name=item.user, - method=item.method, text=item.text, at=item.at) - for item in send: - queued = player.enqueue( - item.text, item.voice, item.speed, item.chime, - sender="planlagt", - who=auth.Principal(item.subject, item.user, (), item.method, frozenset()), - ) - schedule.mark(item.id, "sent", f"kø #{queued.id}") - log.info("planlagt #%s lagt i kø som #%d", item.id, queued.id) - audit("scheduled_fire", id=item.id, queue_id=queued.id, - subject=item.subject, name=item.user, method=item.method, - text=item.text, voice=item.voice, at=item.at) + scheduler_tick() except Exception: # noqa: BLE001 # En feil her skal ikke drepe tråden; da ville alle senere # planlagte sendinger stilltiende blitt borte. @@ -491,7 +562,8 @@ def _scheduler() -> None: time.sleep(TICK) -threading.Thread(target=_scheduler, daemon=True, name="scheduler").start() +def start_scheduler() -> None: + threading.Thread(target=_scheduler_loop, daemon=True, name="scheduler").start() # -------------------------------------------------------------------------- @@ -721,6 +793,29 @@ def not_found(e): # noqa: ANN001 return e +def check_writable() -> None: + """Sier klart fra hvis datamappene ikke kan skrives, før noe annet. + + Det vanlige tilfellet: `docker compose up` uten install.sh, så Docker + laget data/ som root mens containeren kjører som vanlig bruker. Uten + denne sjekken feiler det først ved første utrop, med en traceback. + """ + for name, path in (("cache", CACHE_DIR), ("logs", LOG_PATH.parent), + ("instance", CFG.path("state")), ("schedule", SCHEDULE_PATH.parent)): + try: + path.mkdir(parents=True, exist_ok=True) + probe = path / ".skrivetest" + probe.touch() + probe.unlink() + except OSError as e: + raise SystemExit( + f"Får ikke skrevet til {path} ({e}).\n" + f"Kjører du i Docker: data/ må eies av brukeren i PUID/PGID i .env, " + f"ikke root. Fiks med sudo chown -R $(id -u):$(id -g) data/ " + f"eller kjør ./install.sh, som lager mappene riktig." + ) from e + + def main() -> None: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--host", default=CFG.get("server", "host", "0.0.0.0")) @@ -728,15 +823,24 @@ def main() -> None: args = ap.parse_args() logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.getLogger("werkzeug").setLevel(logging.WARNING) + check_writable() ensure_voices() load_voice("no_NO-talesyntese-medium") # varm opp reserven if CFG.source: log.info("konfigurasjon fra %s", CFG.source) methods = ", ".join(m["method"] for m in AUTH.describe()["methods"]) or "åpen (ingen innlogging)" venter = len(schedule.snapshot()["pending"]) + player.start() + start_scheduler() log.info("klar på http://%s:%d (auth: %s, sink: %s, planlagt: %d)", args.host, args.port, methods, SINK or "standard", venter) - app.run(host=args.host, port=args.port, threaded=True) + # waitress, ikke Flask sin utviklingsserver – og ikke gunicorn med flere + # workere: Player og planleggeren er singletons i denne prosessen, så + # flere workere ville gitt flere køer som snakker i munnen på hverandre. + # Én prosess, mange tråder. + from waitress import serve + + serve(app, host=args.host, port=args.port, threads=8, ident="announcer") if __name__ == "__main__": diff --git a/auth.py b/auth.py index 11975f6..0aab945 100644 --- a/auth.py +++ b/auth.py @@ -124,7 +124,6 @@ class TokenVerifier: self.secret = str(section.get("secret", "")).strip() self.jwks_url = str(section.get("jwks_url", "")).strip() self.leeway = int(section.get("leeway", 30)) - self.verify_signature = bool(section.get("verify_signature", True)) # Tom liste: utled av hvordan nøkkelen kommer. Delt hemmelighet er # symmetrisk (HS256), JWKS er asymmetrisk (RS256/ES256). algs = [str(a) for a in section.get("algorithms") or []] @@ -143,9 +142,6 @@ class TokenVerifier: def verify(self, token: str) -> dict: import jwt - if not self.verify_signature: - # Bare for et crew-panel bak en proxy som alt har validert tokenet. - return jwt.decode(token, options={"verify_signature": False}) if not (self.secret or self.jwks_url): raise AuthError("JWT er ikke konfigurert") try: @@ -306,9 +302,6 @@ class OidcProvider(Provider): "secret": self.client_secret, "jwks_url": meta.get("jwks_uri", ""), "algorithms": [], - "verify_signature": bool( - self.cfg.get("auth.oidc", "verify_signature", True) - ), } ) return self._verifier @@ -626,6 +619,14 @@ def logout(): return redirect("/") +def reconfigure(cfg: Config) -> Authenticator: + """Bytter ut leverandørene uten å røre Flask-appen. For tester som vil + prøve samme app med og uten innlogging.""" + global _auth + _auth = Authenticator(cfg) + return _auth + + def install(app, cfg: Config) -> Authenticator: # noqa: ANN001 """Kobler autentisering på Flask-appen. Returnerer Authenticatoren.""" global _auth diff --git a/config.py b/config.py index 5cfe9cf..8cb14ef 100644 --- a/config.py +++ b/config.py @@ -112,7 +112,6 @@ DEFAULTS: dict[str, Any] = { "jwks_url": "", "end_session_endpoint": "", "label": "Logg inn", - "verify_signature": True, }, "roles": { "anonymous": [], diff --git a/index.html b/index.html index 665f0dc..28615b5 100644 --- a/index.html +++ b/index.html @@ -76,6 +76,7 @@ font: inherit; font-size: 13px; padding: 0 4px; } ul.list li button.x:hover { color: var(--danger); } ul.list li.cancelled .s, ul.list li.missed .s { color: var(--warn); } + .now .t { color: var(--muted); font-size: 13px; }
@@ -422,7 +423,8 @@ btnAnnounce.addEventListener('click', async () => { $('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(onError)); $('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(onError)); -const STATUS = { done: 'sendt', failed: 'feilet', stopped: 'stoppet', queued: 'i kø' }; +const STATUS = { done: 'sendt', failed: 'feilet', stopped: 'stoppet', queued: 'i kø', + preparing: 'lager lyd', playing: 'spiller' }; function hhmm(ts) { return new Date(ts * 1000).toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' }); } function li(it) { const l = document.createElement('li'); l.className = it.status; @@ -434,7 +436,8 @@ function li(it) { l.querySelector('.s').textContent = (STATUS[it.status] || it.status) + (it.error ? ' · ' + it.error : ''); return l; } -const SCHED_STATUS = { sent: 'sendt', missed: 'gikk glipp av', cancelled: 'avbrutt' }; +const SCHED_STATUS = { queued: 'i kø', sent: 'sendt', failed: 'feilet', stopped: 'stoppet', + missed: 'gikk glipp av', cancelled: 'avbrutt' }; function schedLi(it) { const l = document.createElement('li'); l.className = it.status; @@ -468,7 +471,14 @@ async function poll() { const rows = [...sc.pending, ...sc.done]; $('#schedcard').classList.toggle('hidden', rows.length === 0); $('#sched').replaceChildren(...rows.map(schedLi)); - if (s.current) { now.className = 'now'; now.innerHTML = ''; now.append(document.createTextNode(s.current.text)); } + if (s.current) { + // «Lager lyd» er de første sekundene av hver edge-tts-melding: det + // er stille, og uten dette så det ut som avspillingen hang. + const preparing = s.current.status === 'preparing'; + now.className = 'now' + (preparing ? ' idle' : ''); + now.innerHTML = preparing ? 'Lager lyd … ' : ''; + now.append(document.createTextNode(s.current.text)); + } else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; } $('#queue').replaceChildren(...s.queued.map(li)); $('#history').replaceChildren(...s.history.map(li)); diff --git a/pyproject.toml b/pyproject.toml index 9f65df8..64aedf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "flask>=3.1.3", "piper-tts>=1.8.0", "pyjwt[crypto]>=2.10", + "waitress>=3.0", ] [dependency-groups] diff --git a/schedule.py b/schedule.py index 04dbcea..97ab15b 100644 --- a/schedule.py +++ b/schedule.py @@ -37,7 +37,9 @@ DEFAULT_MAX_AHEAD = 30 * 86400.0 # Hvor mange ferdige (sendt/hoppet over/avbrutt) vi beholder for visning. KEEP_DONE = 50 -STATUSES = ("pending", "sent", "missed", "cancelled") +# pending -> queued (lagt i kø) -> sent | failed | stopped +# -> missed (forfalt mens maskina var av) -> cancelled (avbrutt) +STATUSES = ("pending", "queued", "sent", "failed", "stopped", "missed", "cancelled") @dataclass @@ -52,7 +54,7 @@ class Scheduled: subject: str = "" method: str = "none" created: float = field(default_factory=time.time) - status: str = "pending" # pending | sent | missed | cancelled + status: str = "pending" # se STATUSES note: str = "" def public(self) -> dict: diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..f9074e1 --- /dev/null +++ b/tests/test_routes.py @@ -0,0 +1,345 @@ +"""Tester for HTTP-laget i announcer.py: rutene, køen og planleggeren. + +Fram til nå var alt dette uprøvd, fordi `import announcer` startet tråder og +dro inn Piper. Nå starter trådene først i main(), og syntese og avspilling +byttes ut her – så kan køen kjøres ett steg om gangen med run_once(). +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import threading +import time +import wave +from pathlib import Path + +import jwt as pyjwt +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +# Må settes før import: announcer.py leser konfigurasjonen på modulnivå. +_TMP = Path(tempfile.mkdtemp(prefix="announcer-routes-")) +for _k, _v in { + "ANNOUNCER_CONFIG": "/finnes-ikke.toml", + "ANNOUNCER_CACHE": _TMP / "cache", + "ANNOUNCER_LOG": _TMP / "logs" / "a.jsonl", + "ANNOUNCER_STATE": _TMP / "instance", + "ANNOUNCER_SCHEDULE": _TMP / "scheduled.json", + "ANNOUNCER_VOICES": _TMP / "voices", + "ANNOUNCER_CHIMES": _TMP / "chimes", +}.items(): + os.environ[_k] = str(_v) + +import announcer # noqa: E402 +import auth # noqa: E402 +import config # noqa: E402 +import schedule as sched # noqa: E402 + +SECRET = "test-hemmelighet-som-er-lang-nok-for-hmac-sha256-ok" + + +def tiny_wav(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + with wave.open(str(path), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(8000) + w.writeframes(b"\x00\x00" * 80) + return path + + +@pytest.fixture +def app(tmp_path, monkeypatch): + """Frisk kø, frisk plan, falsk syntese og «avspilling» som bare lykkes.""" + monkeypatch.setattr(announcer, "LOG_PATH", tmp_path / "a.jsonl") + monkeypatch.setattr(announcer, "CACHE_DIR", tmp_path / "cache") + monkeypatch.setattr(announcer, "schedule", sched.Schedule(tmp_path / "s.json", grace=120)) + monkeypatch.setattr(announcer, "player", announcer.Player(on_finished=announcer._scheduled_finished)) + monkeypatch.setattr(announcer, "synthesize", + lambda text, voice, speed, chime: tiny_wav(tmp_path / "cache" / "x.wav")) + monkeypatch.setattr(announcer, "player_cmd", lambda path: ["true"]) + auth.reconfigure(config.load("/finnes-ikke.toml")) + announcer.app.config["TESTING"] = True + return announcer.app + + +@pytest.fixture +def c(app): + return app.test_client() + + +def audit_lines(tmp_path) -> list[dict]: + p = tmp_path / "a.jsonl" + return [json.loads(l) for l in p.read_text().splitlines()] if p.exists() else [] + + +def with_auth(tmp_path, toml: str) -> None: + p = tmp_path / "c.toml" + p.write_text(f'[paths]\nstate = "{tmp_path}"\n\n{toml}') + auth.reconfigure(config.load(p)) + + +def token(sub: str, name: str, roles: list[str]) -> dict: + t = pyjwt.encode({"iss": "https://crew.test", "aud": "announcer", "sub": sub, + "name": name, "roles": roles, "exp": time.time() + 600}, + SECRET, algorithm="HS256") + return {"Authorization": f"Bearer {t}"} + + +JWT_CFG = f'[auth.jwt]\nsecret = "{SECRET}"\naudience = "announcer"\nissuer = "https://crew.test"\n' + + +# -------------------------------------------------------------------------- +# Grunnleggende +# -------------------------------------------------------------------------- + + +def test_sida_og_konfig(c): + assert c.get("/").status_code == 200 + d = c.get("/api/config").get_json() + assert d["app"]["title"] == "Announcer" + assert d["auth"]["enabled"] is False + assert "schedule" in d["auth"]["can"] + + +def test_ukjent_api_rute_svarer_json(c): + r = c.get("/api/finnes-ikke") + assert r.status_code == 404 + assert r.get_json()["error"] == "Finnes ikke" + + +@pytest.mark.parametrize("body, feil", [ + ({}, "Tom melding"), + ({"text": " "}, "Tom melding"), + ({"text": "x" * 601}, "Maks 600 tegn"), + ({"text": "hei", "voice": "finnes-ikke"}, "Ukjent stemme"), + ({"text": "hei", "chime": "finnes-ikke"}, "Ukjent jingle"), +]) +def test_ugyldig_melding_gir_400(c, body, feil): + r = c.post("/api/announce", json=body) + assert r.status_code == 400 + assert r.get_json()["error"] == feil + + +def test_hastighet_klemmes_inn(c): + c.post("/api/announce", json={"text": "fort", "speed": 9}) + c.post("/api/announce", json={"text": "sakte", "speed": 0.1}) + assert [i.speed for i in announcer.player.history] == [1.6, 0.6] + + +def test_whitespace_normaliseres(c): + c.post("/api/announce", json={"text": " hei på\n\ndeg "}) + assert announcer.player.history[0].text == "hei på deg" + + +# -------------------------------------------------------------------------- +# Køen +# -------------------------------------------------------------------------- + + +def test_announce_legger_i_ko_og_spiller(c, tmp_path): + r = c.post("/api/announce", json={"text": "Hallo", "chime": "none"}) + assert r.status_code == 200 + assert r.get_json()["status"] == "queued" + + item = announcer.player.run_once(timeout=1) + assert item.status == "done" + actions = [l["action"] for l in audit_lines(tmp_path)] + assert actions == ["announce", "played"] + assert audit_lines(tmp_path)[1]["status"] == "done" + + +def test_feilet_avspilling_markeres(c, monkeypatch): + monkeypatch.setattr(announcer, "player_cmd", lambda path: ["false"]) + c.post("/api/announce", json={"text": "x"}) + assert announcer.player.run_once(timeout=1).status == "failed" + + +def test_syntesefeil_markeres(c, monkeypatch): + def boom(*a): + raise RuntimeError("edge-tts nede") + monkeypatch.setattr(announcer, "synthesize", boom) + c.post("/api/announce", json={"text": "x"}) + item = announcer.player.run_once(timeout=1) + assert item.status == "failed" + assert "edge-tts nede" in item.error + + +def test_stopp_under_syntese_hindrer_avspilling(c, monkeypatch): + """Bug nr. 1 fra gjennomgangen: Stopp gjorde ingenting før lyden var + laget, og meldingen gikk ut likevel. Nå skal den aldri nå avspilling.""" + spilt = [] + def synth_som_blir_stoppet(text, voice, speed, chime): + announcer.player.stop() # noen trykker Stopp midt i syntesen + return tiny_wav(announcer.CACHE_DIR / "x.wav") + monkeypatch.setattr(announcer, "synthesize", synth_som_blir_stoppet) + monkeypatch.setattr(announcer, "player_cmd", lambda p: spilt.append(p) or ["true"]) + + c.post("/api/announce", json={"text": "feil melding"}) + item = announcer.player.run_once(timeout=1) + assert item.status == "stopped" + assert spilt == [], "avspilling skulle aldri startet" + + +def test_stopp_under_avspilling(c, monkeypatch): + monkeypatch.setattr(announcer, "player_cmd", lambda p: ["sleep", "5"]) + c.post("/api/announce", json={"text": "lang"}) + t = threading.Thread(target=announcer.player.run_once, kwargs={"timeout": 1}) + t.start() + for _ in range(50): # vent til prosessen faktisk kjører + if announcer.player.proc is not None: + break + time.sleep(0.02) + r = c.post("/api/stop") + t.join(timeout=3) + assert r.get_json() == {"ok": True} + assert announcer.player.history[0].status == "stopped" + assert not t.is_alive(), "sleep 5 skulle vært drept" + + +def test_tom_ko(c): + for i in range(3): + c.post("/api/announce", json={"text": f"m{i}"}) + assert c.post("/api/clear").status_code == 200 + assert [i.status for i in announcer.player.history] == ["stopped"] * 3 + assert announcer.player.run_once(timeout=0.2) is None, "køen skal være tom" + assert c.get("/api/status").get_json()["queued"] == [] + + +def test_status_viser_ko_og_historikk(c): + c.post("/api/announce", json={"text": "en"}) + c.post("/api/announce", json={"text": "to"}) + announcer.player.run_once(timeout=1) + s = c.get("/api/status").get_json() + assert s["current"] is None + assert [i["text"] for i in s["queued"]] == ["to"] + assert [i["text"] for i in s["history"]] == ["en"] + assert "scheduled" in s and "now" in s + + +def test_ip_vises_bare_for_den_som_kan_tomme_koen(c, tmp_path): + with_auth(tmp_path, JWT_CFG) + crew = token("7", "Kari", ["crew"]) + adm = token("1", "Kent", ["admin"]) + c.post("/api/announce", json={"text": "x"}, headers=crew) + assert c.get("/api/status", headers=crew).get_json()["queued"][0]["sender"] == "" + assert c.get("/api/status", headers=adm).get_json()["queued"][0]["sender"] == "127.0.0.1" + + +def test_401_i_pin_modus_sier_logg_inn(c, tmp_path): + with_auth(tmp_path, '[auth.pin]\npin = "1234"\n') + r = c.post("/api/announce", json={"text": "x"}) + assert r.status_code == 401 + assert r.get_json() == {"error": "Feil eller manglende PIN.", "login": True} + assert c.get("/api/status").status_code == 401 + + +# -------------------------------------------------------------------------- +# Planlagte sendinger gjennom HTTP og planleggeren +# -------------------------------------------------------------------------- + + +def test_planlegging_og_fyring(c, tmp_path): + at = time.time() + 600 + r = c.post("/api/schedule", json={"text": "Senere", "chime": "none", "at": at}) + assert r.status_code == 200 + sid = r.get_json()["id"] + assert [i["id"] for i in c.get("/api/status").get_json()["scheduled"]["pending"]] == [sid] + + # Ikke forfalt: ingenting skjer + announcer.scheduler_tick() + assert announcer.player.history == [] + + # Skru klokka fram: forfalt, innenfor nådefristen + announcer.schedule.get(sid).at = time.time() - 10 + announcer.scheduler_tick() + assert announcer.schedule.get(sid).status == "queued" + assert announcer.player.history[0].schedule_id == sid + + # Bug nr. 2: statusen skal følge avspillingen helt i mål + announcer.player.run_once(timeout=1) + assert announcer.schedule.get(sid).status == "sent" + assert [l["action"] for l in audit_lines(tmp_path)] == ["schedule", "scheduled_fire", "played"] + + +def test_planlagt_som_feiler_i_avspilling_star_som_feilet(c, monkeypatch): + monkeypatch.setattr(announcer, "player_cmd", lambda p: ["false"]) + sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600}).get_json()["id"] + announcer.schedule.get(sid).at = time.time() - 10 + announcer.scheduler_tick() + announcer.player.run_once(timeout=1) + assert announcer.schedule.get(sid).status == "failed" + + +def test_planlagt_som_gikk_glipp_av_spilles_ikke(c, tmp_path): + sid = c.post("/api/schedule", json={"text": "for sent", "at": time.time() + 600}).get_json()["id"] + announcer.schedule.get(sid).at = time.time() - 3600 + announcer.scheduler_tick() + assert announcer.schedule.get(sid).status == "missed" + assert announcer.player.history == [] + assert [l["action"] for l in audit_lines(tmp_path)] == ["schedule", "missed"] + + +@pytest.mark.parametrize("body, feil", [ + ({"text": "x"}, "Mangler eller ugyldig tidspunkt"), + ({"text": "x", "at": "snart"}, "Mangler eller ugyldig tidspunkt"), + ({"text": "x", "at": 1}, "Tidspunktet er passert"), + ({"text": "x", "at": time.time() + 400 * 86400}, "Mer enn 30 dager fram i tid"), + ({"text": "", "at": time.time() + 60}, "Tom melding"), +]) +def test_ugyldig_planlegging(c, body, feil): + r = c.post("/api/schedule", json=body) + assert r.status_code == 400 + assert r.get_json()["error"] == feil + + +def test_avbryt_egen_planlagt(c, tmp_path): + sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600}).get_json()["id"] + assert c.post("/api/schedule/cancel", json={"id": sid}).status_code == 200 + assert announcer.schedule.get(sid).status == "cancelled" + assert c.post("/api/schedule/cancel", json={"id": sid}).status_code == 404 + assert c.post("/api/schedule/cancel", json={"id": "nope"}).status_code == 404 + + +def test_andres_planlagte_krever_clear(c, tmp_path): + with_auth(tmp_path, JWT_CFG) + kari = token("7", "Kari", ["crew"]) + ola = token("8", "Ola", ["crew"]) + kent = token("1", "Kent", ["admin"]) + sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600}, headers=kari).get_json()["id"] + + r = c.post("/api/schedule/cancel", json={"id": sid}, headers=ola) + assert r.status_code == 403 + assert "Kari" in r.get_json()["error"] + assert c.post("/api/schedule/cancel", json={"id": sid}, headers=kari).status_code == 200 + + sid2 = c.post("/api/schedule", json={"text": "y", "at": time.time() + 600}, headers=kari).get_json()["id"] + assert c.post("/api/schedule/cancel", json={"id": sid2}, headers=kent).status_code == 200 + + +def test_schedule_krever_rettigheten(c, tmp_path): + with_auth(tmp_path, JWT_CFG + '\n[roles]\nlytter = ["view", "announce"]\n') + h = token("9", "Lytter", ["lytter"]) + assert c.post("/api/announce", json={"text": "x"}, headers=h).status_code == 200 + assert c.post("/api/schedule", json={"text": "x", "at": time.time() + 60}, headers=h).status_code == 403 + + +# -------------------------------------------------------------------------- +# Oppstart +# -------------------------------------------------------------------------- + + +def test_uskrivbar_datamappe_gir_klar_melding(tmp_path, monkeypatch): + locked = tmp_path / "laast" + locked.mkdir() + locked.chmod(0o500) + monkeypatch.setattr(announcer, "CACHE_DIR", locked / "cache") + with pytest.raises(SystemExit) as e: + announcer.check_writable() + assert "PUID" in str(e.value) and "chown" in str(e.value) + locked.chmod(0o700) diff --git a/uv.lock b/uv.lock index 2de02a3..713961f 100644 --- a/uv.lock +++ b/uv.lock @@ -155,6 +155,7 @@ dependencies = [ { name = "flask" }, { name = "piper-tts" }, { name = "pyjwt", extra = ["crypto"] }, + { name = "waitress" }, ] [package.dev-dependencies] @@ -168,6 +169,7 @@ requires-dist = [ { name = "flask", specifier = ">=3.1.3" }, { name = "piper-tts", specifier = ">=1.8.0" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10" }, + { name = "waitress", specifier = ">=3.0" }, ] [package.metadata.requires-dev] @@ -1030,6 +1032,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "waitress" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" }, +] + [[package]] name = "werkzeug" version = "3.1.9"