Files
livekit/pkg/agent/endpoint/template_test.go
T
Théo Monnom 8c7011cb7a agent: HTTP endpoints data plane
Serve worker-declared FastAPI routes at /agents/{deployment}/{path} without
any worker-side listener: workers dial a fixed pool of wires speaking
AgentHttp.Frame, the server opens multiplexed streams carrying one opaque
HTTP/1.1 exchange each (two-level credit flow control, prioritized write
scheduler, attach epoch fencing). The front does starlette-exact manifest
matching with per-endpoint public access, typed 401/404/405, a retry table,
and SSE/WebSocket passthrough; a pluggable fallback hook lets multi-node
deployments resolve misses elsewhere. Includes a conformance client, a
manual sidecar, and the acceptance suite.

The auth middleware now reads access_token from the query string only:
FormValue consumed the bodies of proxied url-encoded POSTs.
2026-08-19 18:25:35 -07:00

124 lines
3.9 KiB
Go

package endpoint
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/livekit/protocol/livekit"
)
func TestTemplateStarletteSemantics(t *testing.T) {
cases := []struct {
template string
path string
match bool
}{
{"/token", "/token", true},
{"/token", "/token/", false},
{"/token", "/Token", false},
{"/users/{id}", "/users/42", true},
{"/users/{id}", "/users/42/posts", false},
{"/users/{id}", "/users/", false},
{"/users/{id:int}", "/users/42", true},
{"/users/{id:int}", "/users/4x2", false},
{"/files/{p:path}", "/files/a/b/c.txt", true},
{"/files/{p:path}", "/files/", true},
{"/price/{v:float}", "/price/1.25", true},
{"/price/{v:float}", "/price/1.", false},
{"/obj/{u:uuid}", "/obj/123e4567-e89b-12d3-a456-426614174000", true},
// starlette's uuid convertor makes every hyphen optional
{"/obj/{u:uuid}", "/obj/123e4567e89b12d3a456426614174000", true},
{"/obj/{u:uuid}", "/obj/123e4567", false},
{"/a/{x}/b/{y}", "/a/1/b/2", true},
{"/a/{x}/b/{y}", "/a/1/c/2", false},
}
for _, c := range cases {
tpl, err := ParseTemplate(c.template)
require.NoError(t, err, c.template)
require.Equal(t, c.match, tpl.Match(c.path), "%s vs %s", c.template, c.path)
}
}
func TestTemplateRejectsCustomConvertors(t *testing.T) {
_, err := ParseTemplate("/x/{id:slug}")
require.Error(t, err)
_, err = ParseTemplate("/x/{a}/{a}")
require.Error(t, err)
_, err = ParseTemplate("no-slash")
require.Error(t, err)
}
func ep(path string, methods []string, public bool) *livekit.AgentHttp_AgentEndpoint {
return &livekit.AgentHttp_AgentEndpoint{Path: path, Methods: methods, Public: public}
}
func TestManifestFullPartialSemantics(t *testing.T) {
// POST /x registered after GET /x must still serve POSTs (starlette scans
// for a FULL match before settling for the PARTIAL 405)
m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{
ep("/x", []string{"GET"}, true),
ep("/x", []string{"POST"}, true),
})
require.NoError(t, err)
r, res := m.Match("/x", http.MethodPost, false)
require.Equal(t, MatchFull, res)
require.Contains(t, r.Methods, "POST")
// the manifest carries the app's methods verbatim: FastAPI does not imply
// HEAD from GET, so neither does the matcher
_, res = m.Match("/x", http.MethodHead, false)
require.Equal(t, MatchPartial, res)
// PARTIAL only when no route serves the method
_, res = m.Match("/x", http.MethodDelete, false)
require.Equal(t, MatchPartial, res)
_, res = m.Match("/nope", http.MethodGet, false)
require.Equal(t, MatchNone, res)
}
func TestManifestRedirectSlashes(t *testing.T) {
m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{
ep("/hook", []string{"POST"}, true),
})
require.NoError(t, err)
alt, ok := m.RedirectSlashes("/hook/", http.MethodPost, false)
require.True(t, ok)
require.Equal(t, "/hook", alt)
_, ok = m.RedirectSlashes("/other/", http.MethodPost, false)
require.False(t, ok)
}
func TestManifestWebSocketRoutes(t *testing.T) {
m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{
{Path: "/ws", Kind: livekit.AgentHttp_AEK_WEBSOCKET, Public: true},
ep("/http", []string{"GET"}, true),
})
require.NoError(t, err)
_, res := m.Match("/ws", http.MethodGet, true)
require.Equal(t, MatchFull, res)
_, res = m.Match("/ws", http.MethodGet, false)
require.Equal(t, MatchNone, res)
_, res = m.Match("/http", http.MethodGet, true)
require.Equal(t, MatchNone, res)
}
func TestManifestValidation(t *testing.T) {
_, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x", nil, false)})
require.Error(t, err, "http endpoint without methods")
_, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x", []string{"get"}, false)})
require.Error(t, err, "lowercase method")
_, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{
{Path: "/ws", Kind: livekit.AgentHttp_AEK_WEBSOCKET, Methods: []string{"GET"}},
})
require.Error(t, err, "websocket route with methods")
}