mavweb: gate the voice path on step-up like the text path

POST /api/ptt and /ws were listed as ungated on the grounds that mavend's
voice port is only reachable inside the deploy. mavweb is the thing proxying
into it from outside, so that argument does not hold. Audio posted to
/api/ptt runs the same router, the same LLM and the same applyAction that
POST /api/chat was gated on, which means speaking a light-switch act reached
the act path while typing it did not.

Both now take stepUpOK, so they fail open by default and deny under
-require-stepup exactly like the other four. Registration moved down next to
/api/chat because the gate needs stepUpSession. The route table records the
reason and names the session-scoped assertion the hands-free case wants as a
separate task. The SECURITY startup lines are one surface per line now.

Found in review of #51.
This commit is contained in:
kami
2026-08-01 13:55:20 +04:00
parent 7f42cc73be
commit 49dfeb879e
4 changed files with 137 additions and 13 deletions
+79
View File
@@ -1172,3 +1172,82 @@ func TestHandleTools_GET_MCPUnavailable(t *testing.T) {
t.Error("expected the empty-state copy")
}
}
// --- voice-path step-up gate (Vikunja #317) ---
//
// POST /api/ptt and GET /ws proxy audio into mavend's voice port, which runs
// the same router, LLM and act path as POST /api/chat. They used to be
// ungated on the grounds that the voice port is only reachable inside the
// deploy, but mavweb is the thing proxying into it from outside. Speaking
// "выключи свет" is not a smaller act than typing it.
// unreachableVoice is a closed port: a request that clears the gate fails at
// the dial with 503, which is how these tests tell "passed" from "denied".
const unreachableVoice = "127.0.0.1:1"
func pttReq() *http.Request {
return httptest.NewRequest(http.MethodPost, "/api/ptt", strings.NewReader("PCM-ish bytes"))
}
func TestHandlePTT_RequireStepUp_FailsClosed(t *testing.T) {
rr := httptest.NewRecorder()
handlePTT(rr, pttReq(), unreachableVoice, nil, true)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
}
}
func TestHandlePTT_UnassertedSession_Denied(t *testing.T) {
rr := httptest.NewRecorder()
handlePTT(rr, pttReq(), unreachableVoice, webauthn.NewPasskeySession(5*time.Minute), false)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
}
}
func TestHandlePTT_AssertedSession_PassesGate(t *testing.T) {
rr := httptest.NewRecorder()
handlePTT(rr, pttReq(), unreachableVoice, stepUpSession(), true)
if rr.Code == http.StatusForbidden {
t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String())
}
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 from the dial past the gate; body=%s", rr.Code, rr.Body.String())
}
}
// Default deploy: WebAuthn unconfigured and -require-stepup off ⇒ push-to-talk
// keeps working, resting on the transport-level auth in front of mavweb.
func TestHandlePTT_FailOpenByDefault(t *testing.T) {
rr := httptest.NewRecorder()
handlePTT(rr, pttReq(), unreachableVoice, nil, false)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 from the dial past the gate; body=%s", rr.Code, rr.Body.String())
}
}
func TestHandleWS_RequireStepUp_FailsClosed(t *testing.T) {
rr := httptest.NewRecorder()
handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, nil, true)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
}
}
func TestHandleWS_UnassertedSession_Denied(t *testing.T) {
rr := httptest.NewRecorder()
handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, webauthn.NewPasskeySession(5*time.Minute), false)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
}
}
// Past the gate the handshake itself fails (httptest's recorder cannot be
// hijacked), which is not a 403. That is all this asserts: the gate let it by.
func TestHandleWS_AssertedSession_PassesGate(t *testing.T) {
rr := httptest.NewRecorder()
handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, stepUpSession(), true)
if rr.Code == http.StatusForbidden {
t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String())
}
}