281 lines
17 KiB
Python
281 lines
17 KiB
Python
from __future__ import annotations
|
|||
|
|
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
import secrets
|
||
|
|
import tempfile
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
import unittest
|
||
|
|
from unittest import mock
|
||
|
|
|
||
|
|
from hub_case import HubCase
|
||
|
|
from hub_engines import log_rows, wav_bytes
|
||
|
|
from hub_support import HOST_STATE, key, signing
|
||
|
|
from kvb_orchestrator import engines, exchange, signed, skill_scripts, voicenotes
|
||
|
|
|
||
|
|
|
||
|
|
class TestHubComposer(HubCase):
|
||
|
|
def clip(self, sample: bytes) -> bytes:
|
||
|
|
return wav_bytes(1, 2, engines.STT_RATE, sample * engines.STT_RATE)
|
||
|
|
|
||
|
|
def record(self, body: bytes, headers: dict[str, str]) -> tuple[int, dict, bytes]:
|
||
|
|
return self.call("POST", "/api/voice", body, {"Content-Type": "audio/wav", **headers})
|
||
|
|
|
||
|
|
def note(self, body: bytes) -> str:
|
||
|
|
status, _, answer = self.record(body, self.guard(self.host))
|
||
|
|
self.assertEqual(status, 202, answer)
|
||
|
|
note = json.loads(answer)
|
||
|
|
self.settle(lambda: self.heard(note["id"])["status"] != "pending")
|
||
|
|
return note["id"]
|
||
|
|
|
||
|
|
def heard(self, note_id: str) -> dict:
|
||
|
|
status, _, body = self.get(f"/api/voice/{note_id}")
|
||
|
|
self.assertEqual(status, 200, body)
|
||
|
|
return json.loads(body)
|
||
|
|
|
||
|
|
def submit(self, body: bytes) -> tuple[int, bytes]:
|
||
|
|
status, _, answer = self.record(body, self.guard(self.host))
|
||
|
|
if status == 202:
|
||
|
|
note = json.loads(answer)["id"]
|
||
|
|
self.settle(lambda: self.heard(note)["status"] != "pending")
|
||
|
|
return status, answer
|
||
|
|
|
||
|
|
def hear(self, body: bytes) -> tuple[int, bytes]:
|
||
|
|
status, _, answer = self.call("POST", "/api/stt", body, {**self.guard(self.host), "Content-Type": "audio/wav"})
|
||
|
|
return status, answer
|
||
|
|
|
||
|
|
def check(self, message_id: str, text: str, to: str) -> dict:
|
||
|
|
return self.tool("operator_message", {"id": message_id, "text": text, "to": to})
|
||
|
|
|
||
|
|
def test_a_recording_is_transcribed_off_the_request_and_kept_for_replay(self):
|
||
|
|
good = self.guard(self.host)
|
||
|
|
clip = self.clip(b"\x01\x00")
|
||
|
|
release = threading.Event()
|
||
|
|
whisper = self.httpd.hub.speech.whisper
|
||
|
|
|
||
|
|
def slow(body: bytes) -> dict:
|
||
|
|
release.wait(10)
|
||
|
|
return whisper(body)
|
||
|
|
|
||
|
|
with mock.patch.object(self.httpd.hub.speech, "whisper", side_effect=slow):
|
||
|
|
status, _, body = self.record(clip, good)
|
||
|
|
self.assertEqual(status, 202, body)
|
||
|
|
note = json.loads(body)
|
||
|
|
self.assertEqual((note["status"], note["text"], note["seconds"]), ("pending", "", 1.0))
|
||
|
|
self.assertEqual(self.heard(note["id"])["status"], "pending")
|
||
|
|
release.set()
|
||
|
|
self.settle(lambda: self.heard(note["id"])["status"] == "done")
|
||
|
|
self.assertEqual(self.heard(note["id"])["text"], "hello from the mic second line")
|
||
|
|
status, headers, body = self.get(f"/api/voice/{note['id']}.wav")
|
||
|
|
self.assertEqual((status, headers["content-type"], headers["content-security-policy"], body), (200, "audio/wav", exchange.PLAIN_CSP, clip))
|
||
|
|
self.assertEqual((self.state / "voice" / f"{note['id']}.wav").read_bytes(), clip)
|
||
|
|
failed = self.note(wav_bytes(1, 2, engines.STT_RATE, b"FAIL" * 100))
|
||
|
|
self.assertEqual(self.heard(failed)["status"], "failed")
|
||
|
|
self.assertIn("exit code 4", self.heard(failed)["error"])
|
||
|
|
self.assertEqual(self.get(f"/api/voice/{failed}.wav")[0], 200)
|
||
|
|
for path in ("/api/voice/0123456789abcdef", "/api/voice/0123456789abcdef.wav", "/api/voice/../tts", "/api/voice/x.wav"):
|
||
|
|
with self.subTest(path=path):
|
||
|
|
self.assertEqual(self.get(path)[0], 404)
|
||
|
|
self.assertEqual(self.get("/api/voice")[0], 405)
|
||
|
|
self.assertEqual(len(log_rows(self.engines.scratch / "whisper.jsonl")), 2)
|
||
|
|
self.assertEqual(self.scripts.calls, [])
|
||
|
|
|
||
|
|
def test_a_recording_is_checked_for_guard_type_size_length_and_load(self):
|
||
|
|
good = self.guard(self.host)
|
||
|
|
clip = self.clip(b"\x01\x00")
|
||
|
|
for headers in ({k: v for k, v in good.items() if k != "X-Kvb-Orchestrator"}, {k: v for k, v in good.items() if k != "Origin"}, {**good, "Origin": "http://evil.example"}):
|
||
|
|
with self.subTest(headers=headers):
|
||
|
|
self.assertEqual(self.record(clip, headers)[0], 403)
|
||
|
|
self.assertEqual(self.call("POST", "/api/voice", clip, {**good, "Content-Type": "audio/webm"})[0], 415)
|
||
|
|
self.assertEqual(self.record(b"RIFF", {**good, "Content-Length": str(engines.STT_LIMIT + 1)})[0], 413)
|
||
|
|
for broken in (b"not a wav", wav_bytes(1, 2, 44100, b"\x00\x00" * 100), wav_bytes(1, 2, engines.STT_RATE, b"")):
|
||
|
|
with self.subTest(broken=broken[:20]):
|
||
|
|
self.assertEqual(self.record(broken, good)[0], 400)
|
||
|
|
with mock.patch.object(engines, "STT_SECONDS", 1):
|
||
|
|
self.assertEqual(self.record(wav_bytes(1, 2, engines.STT_RATE, b"\x00\x00" * (engines.STT_RATE + 1)), good)[0], 400)
|
||
|
|
with mock.patch.object(self.httpd.hub.speech, "input_gap", return_value="the whisper.cpp model m is not a file"):
|
||
|
|
self.assertEqual(self.record(clip, good)[:3:2], (503, b"speech input is off: the whisper.cpp model m is not a file\n"))
|
||
|
|
release = threading.Event()
|
||
|
|
self.addCleanup(release.set)
|
||
|
|
with mock.patch.object(self.httpd.hub.speech, "whisper", side_effect=lambda body: release.wait(10) and {"text": ""}):
|
||
|
|
for _ in range(voicenotes.HEARING_LIMIT):
|
||
|
|
self.assertEqual(self.record(clip, good)[0], 202)
|
||
|
|
self.assertEqual(self.record(clip, good)[0], 503)
|
||
|
|
release.set()
|
||
|
|
self.assertEqual(list((self.state / "voice").glob(".*")), [])
|
||
|
|
|
||
|
|
def test_only_the_newest_recordings_are_kept(self):
|
||
|
|
with mock.patch.object(voicenotes, "VOICE_KEEP", 2):
|
||
|
|
first, second, third = (self.note(self.clip(bytes([n, 0]))) for n in (1, 2, 3))
|
||
|
|
self.assertEqual(self.get(f"/api/voice/{first}.wav")[0], 404)
|
||
|
|
self.assertEqual([self.get(f"/api/voice/{note}.wav")[0] for note in (second, third)], [200, 200])
|
||
|
|
self.assertEqual(sorted(path.stem for path in (self.state / "voice").iterdir()), sorted([second, third]))
|
||
|
|
|
||
|
|
def test_an_operator_message_needs_the_passkey_and_carries_its_recording(self):
|
||
|
|
self.claim("s1", "Skills session")
|
||
|
|
voice = self.note(self.clip(b"\x01\x00"))
|
||
|
|
payload = {"to": "Skills", "text": " bitte rebasen ", "upload": "", "class": "call", "voice": voice}
|
||
|
|
status, _, body = self.post_json("/api/messages/sign", payload, self.guard(self.host))
|
||
|
|
self.assertEqual(status, 200, body)
|
||
|
|
self.assertEqual(json.loads(body)["credentials"], [])
|
||
|
|
unpinned = key(-7, "stranger").proof(json.loads(body)["payload"], self.origin)
|
||
|
|
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": unpinned}, self.guard(self.host))[:3:2], (400, b"nicht signiert: no key is pinned\n"))
|
||
|
|
signer = self.pin()
|
||
|
|
proof = self.message_proof(payload, signer)
|
||
|
|
for changed, reason in (
|
||
|
|
({"proof": None}, b"nicht signiert: no signature\n"),
|
||
|
|
({"proof": key(-7, "stranger").proof(proof["payload"], self.origin)}, b"nicht signiert: the signing key is not pinned\n"),
|
||
|
|
({"proof": signer.proof(proof["payload"], "http://127.0.0.1:1")}, None),
|
||
|
|
({"text": "bitte mergen", "proof": proof}, b"nicht signiert: the signature does not list this item\n"),
|
||
|
|
({"class": "post", "proof": proof}, b"nicht signiert: the signature does not list this item\n"),
|
||
|
|
({"voice": "", "proof": proof}, b"nicht signiert: the signature does not list this item\n"),
|
||
|
|
):
|
||
|
|
with self.subTest(changed=list(changed)):
|
||
|
|
status, _, answer = self.post_json("/api/messages", {**payload, **changed}, self.guard(self.host))
|
||
|
|
self.assertEqual(status, 400, answer)
|
||
|
|
if reason is not None:
|
||
|
|
self.assertEqual(answer, reason)
|
||
|
|
self.assertEqual(self.scripts.calls, [])
|
||
|
|
status, _, body = self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))
|
||
|
|
self.assertEqual(status, 201, body)
|
||
|
|
sent = json.loads(body)
|
||
|
|
recording = f"{HOST_STATE}/voice/{voice}.wav"
|
||
|
|
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["send", "call", "--to", "Skills session", "--session", "Operator", f"[kvb-orchestrator msg {sent['id']}] bitte rebasen voice: {recording}"])])
|
||
|
|
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))[:3:2], (400, b"nicht signiert: this signature was already used for this item\n"))
|
||
|
|
verified = self.check(sent["id"], "bitte rebasen", "Skills session")["structuredContent"]
|
||
|
|
self.assertEqual((verified["match"], verified["text"], verified["class"], verified["voice"]), (True, "bitte rebasen", "call", recording))
|
||
|
|
row = self.snapshot()["messages"][-1]
|
||
|
|
self.assertEqual((row["id"], row["class"], row["voice"], row["signed"]), (sent["id"], "call", voice, True))
|
||
|
|
self.assertEqual(len(self.scripts.calls), 1)
|
||
|
|
|
||
|
|
def test_a_signed_message_to_all_names_everyone_and_keeps_its_class(self):
|
||
|
|
signer = self.pin()
|
||
|
|
voice = self.note(self.clip(b"\x01\x00"))
|
||
|
|
status, _, body = self.tell_all({"text": "disk full", "class": "mayday", "voice": voice}, signer)
|
||
|
|
self.assertEqual(status, 200, body)
|
||
|
|
script, args = self.scripts.calls[-1]
|
||
|
|
self.assertEqual((script, args[:4]), (skill_scripts.RADIO, ["send", "mayday", "--session", "Operator"]))
|
||
|
|
self.assertTrue(args[-1].endswith(f"] disk full voice: {HOST_STATE}/voice/{voice}.wav"), args[-1])
|
||
|
|
self.assertEqual(self.snapshot()["messages"][-1]["to"], "*")
|
||
|
|
for payload in (
|
||
|
|
{"to": "Skills", "text": "x", "upload": "", "class": "mayday", "voice": ""},
|
||
|
|
{"to": "*", "text": "x", "upload": "0123456789abcdef", "class": "newsletter", "voice": ""},
|
||
|
|
{"to": "*", "text": "x", "upload": "", "class": "receipt", "voice": ""},
|
||
|
|
{"to": "*", "text": " ", "upload": "", "class": "broadcast", "voice": ""},
|
||
|
|
{"to": "*", "text": "x", "upload": "", "class": "broadcast", "voice": "0123456789abcdef"},
|
||
|
|
{"to": "*", "text": "x", "upload": "", "class": "broadcast", "voice": 7},
|
||
|
|
{"to": "*", "text": "x", "upload": "", "class": "broadcast"},
|
||
|
|
):
|
||
|
|
with self.subTest(payload=payload):
|
||
|
|
self.assertEqual(self.post_json("/api/messages/sign", payload, self.guard(self.host))[0], 400)
|
||
|
|
self.assertEqual(self.post_json("/api/messages/sign", {"to": "*", "text": "x", "upload": "", "class": "broadcast", "voice": ""}, self.client())[0], 403)
|
||
|
|
self.assertEqual(self.get("/api/messages/sign")[0], 405)
|
||
|
|
|
||
|
|
def test_operator_message_matches_only_the_signed_text_and_recipient(self):
|
||
|
|
self.claim("s1", "Skills session")
|
||
|
|
signer = self.pin()
|
||
|
|
voice = self.note(self.clip(b"\x01\x00"))
|
||
|
|
status, _, body = self.say({"to": "Skills", "text": "bitte\n rebasen", "class": "call", "voice": voice}, signer)
|
||
|
|
self.assertEqual(status, 201, body)
|
||
|
|
sent = json.loads(body)["id"]
|
||
|
|
row = self.scripts.calls[-1][1][-1].split("] ", 1)[1]
|
||
|
|
for text, to in (("bitte rebasen", "Skills"), ("bitte rebasen", "Skills session"), (row, "Skills")):
|
||
|
|
with self.subTest(text=text, to=to):
|
||
|
|
result = self.check(sent, text, to)
|
||
|
|
self.assertFalse(result["isError"], result)
|
||
|
|
found = result["structuredContent"]
|
||
|
|
self.assertEqual((found["match"], found["text"], found["to"], found["class"]), (True, "bitte\n rebasen", "Skills", "call"))
|
||
|
|
self.assertEqual(found["voice"], f"{HOST_STATE}/voice/{voice}.wav")
|
||
|
|
for text, to in (("bitte mergen", "Skills"), ("bitte rebasen", "Stage"), ("bitte rebasen", "*"), ("bitte", "Skills")):
|
||
|
|
with self.subTest(text=text, to=to):
|
||
|
|
result = self.check(sent, text, to)
|
||
|
|
self.assertTrue(result["isError"], result)
|
||
|
|
self.assertNotIn("structuredContent", result)
|
||
|
|
self.assertEqual(self.tell_all({"text": "main is green", "class": "newsletter"}, signer)[0], 200)
|
||
|
|
everyone = next(message["id"] for message in self.snapshot()["messages"] if message["to"] == "*")
|
||
|
|
self.assertTrue(self.check(everyone, "main is green", "*")["structuredContent"]["match"])
|
||
|
|
self.assertTrue(self.check(everyone, "main is green", "Skills")["isError"])
|
||
|
|
legacy = self.httpd.hub.messages.add({"to": "Skills", "address": "Skills session", "text": "deploy", "upload": None, "created_ts": time.time()})
|
||
|
|
self.assertTrue(self.check(legacy["id"], "deploy", "Skills")["isError"])
|
||
|
|
self.httpd.hub.messages.change(sent, {"text": "rm -rf"})
|
||
|
|
self.assertTrue(self.check(sent, "rm -rf", "Skills")["isError"])
|
||
|
|
self.assertTrue(self.tool("operator_message", {"id": everyone})["isError"])
|
||
|
|
|
||
|
|
def test_a_post_or_a_call_never_goes_to_everyone(self):
|
||
|
|
signer = self.pin()
|
||
|
|
for cls in ("post", "call"):
|
||
|
|
with self.subTest(cls=cls):
|
||
|
|
payload = {"to": " * ", "text": "x", "upload": "", "class": cls, "voice": ""}
|
||
|
|
self.assertEqual(self.post_json("/api/messages/sign", payload, self.guard(self.host))[:3:2], (400, b"a post or a call names one agent, never *\n"))
|
||
|
|
item = signing.message_item("*", cls, "x", "", "", "")
|
||
|
|
request = signing.approval_payload([signing.item_hash(item)], secrets.token_hex(16), int(time.time()) + 60)
|
||
|
|
proof = signer.proof(request, self.origin)
|
||
|
|
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))[0], 400)
|
||
|
|
self.assertEqual(self.scripts.calls, [])
|
||
|
|
|
||
|
|
def test_the_hub_transcribes_a_bounded_number_of_recordings_per_minute(self):
|
||
|
|
answers = [self.submit(self.clip(b"\x01\x00")) for _ in range(12)]
|
||
|
|
statuses = [status for status, _ in answers]
|
||
|
|
self.assertIn(503, statuses)
|
||
|
|
self.assertEqual(statuses, [202] * engines.HEARING_RATE + [503] * (12 - engines.HEARING_RATE))
|
||
|
|
self.assertIn(f"at most {engines.HEARING_RATE} recordings per {engines.HEARING_WINDOW} s".encode(), answers[-1][1])
|
||
|
|
self.assertEqual(self.hear(self.clip(b"\x01\x00"))[0], 503)
|
||
|
|
self.assertEqual(len(log_rows(self.engines.scratch / "whisper.jsonl")), engines.HEARING_RATE)
|
||
|
|
|
||
|
|
def test_the_hub_transcribes_at_most_its_hourly_audio_budget(self):
|
||
|
|
full = wav_bytes(1, 2, engines.STT_RATE, b"\x01\x00" * engines.STT_RATE * engines.STT_SECONDS)
|
||
|
|
statuses = [self.submit(full)[0] for _ in range(5)]
|
||
|
|
status, answer = self.submit(self.clip(b"\x01\x00"))
|
||
|
|
self.assertEqual((statuses, status), ([202] * 5, 503), answer)
|
||
|
|
self.assertEqual(engines.HEARING_BUDGET, 5 * engines.STT_SECONDS)
|
||
|
|
self.assertIn(f"{engines.HEARING_BUDGET} s of audio per hour".encode(), answer)
|
||
|
|
self.assertEqual(self.hear(self.clip(b"\x01\x00"))[0], 503)
|
||
|
|
|
||
|
|
def test_a_recording_a_stored_message_refers_to_is_never_trimmed(self):
|
||
|
|
self.claim("s1", "Skills session")
|
||
|
|
signer = self.pin()
|
||
|
|
with mock.patch.object(voicenotes, "VOICE_KEEP", 1):
|
||
|
|
kept = self.note(self.clip(b"\x01\x00"))
|
||
|
|
self.assertEqual(self.say({"to": "Skills", "text": "hear this", "class": "post", "voice": kept}, signer)[0], 201)
|
||
|
|
newer = [self.note(self.clip(bytes([n, 0]))) for n in (2, 3)]
|
||
|
|
self.assertEqual(self.get(f"/api/voice/{kept}.wav")[0], 200)
|
||
|
|
self.assertEqual([self.get(f"/api/voice/{note}.wav")[0] for note in newer], [404, 200])
|
||
|
|
sending = self.note(self.clip(b"\x04\x00"))
|
||
|
|
with self.httpd.hub.voice_notes.held(sending):
|
||
|
|
self.note(self.clip(b"\x05\x00"))
|
||
|
|
self.assertEqual(self.get(f"/api/voice/{sending}.wav")[0], 200)
|
||
|
|
self.assertEqual(self.get(f"/api/voice/{kept}.wav")[0], 200)
|
||
|
|
|
||
|
|
def test_the_signature_binds_the_bytes_of_the_recording(self):
|
||
|
|
self.claim("s1", "Skills session")
|
||
|
|
signer = self.pin()
|
||
|
|
voice = self.note(self.clip(b"\x01\x00"))
|
||
|
|
wav = self.state / "voice" / f"{voice}.wav"
|
||
|
|
original = wav.read_bytes()
|
||
|
|
payload = {"to": "Skills", "text": "hear this", "upload": "", "class": "call", "voice": voice}
|
||
|
|
proof = self.message_proof(payload, signer)
|
||
|
|
wav.write_bytes(self.clip(b"\x02\x00"))
|
||
|
|
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))[:3:2], (400, b"nicht signiert: the signature does not list this item\n"))
|
||
|
|
self.assertEqual(self.scripts.calls, [])
|
||
|
|
wav.write_bytes(original)
|
||
|
|
status, _, body = self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))
|
||
|
|
self.assertEqual(status, 201, body)
|
||
|
|
found = self.check(json.loads(body)["id"], "hear this", "Skills")["structuredContent"]
|
||
|
|
self.assertEqual(found["voice_sha256"], hashlib.sha256(original).hexdigest())
|
||
|
|
|
||
|
|
def test_a_signature_check_without_a_temporary_directory_answers_400(self):
|
||
|
|
self.claim("s1", "Skills session")
|
||
|
|
signer = self.pin()
|
||
|
|
payload = {"to": "Skills", "text": "x", "upload": "", "class": "post", "voice": ""}
|
||
|
|
proof = self.message_proof(payload, signer)
|
||
|
|
missing = self.state / "no-such-tmp"
|
||
|
|
with mock.patch.object(signed.passkey, "serialization", None), mock.patch.object(tempfile, "tempdir", str(missing)):
|
||
|
|
status, _, body = self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))
|
||
|
|
self.assertEqual(status, 400, body)
|
||
|
|
self.assertTrue(body.startswith(b"nicht signiert: "), body)
|
||
|
|
self.assertEqual(self.scripts.calls, [])
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|