mirror of
https://github.com/hanzoai/world-zap.git
synced 2026-10-30 14:55:43 +00:00
Real-time Hanzo World feeds over ZAP binary protocol (9-byte header +
JSON payload) fronted by WebSockets at wss://zap.world.hanzo.ai/zap.
IAM bearer auth via Authorization header or ?token= query param.
Validator caches /oauth/userinfo for 5 minutes.
Topics (10): world.events.{all,conflicts,earthquakes,fires},
world.markets.{quotes,crypto}, world.ships.ais, world.aviation.opensky,
world.news.live, world.weather.alerts. Minimum plan enforced per topic.
Tools: subscribe, unsubscribe, list_topics. Admin-only publish for
internal service accounts.
Ingest: streams events from worldmonitor GET /v1/world/events?stream=1,
fans out via bounded-channel hub with drop-on-overflow semantics.
Per-user token bucket rate limits: free 30/min, pro 3000/min, team
15000/min, enterprise 60000/min.
Tests: all packages pass -race (proto, auth, hub, ratelimit, ingest,
handlers). Multi-arch image build wired to hanzoai/.github docker-build
workflow.
271 lines
7.1 KiB
Go
271 lines
7.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
|
|
"github.com/hanzoai/world-zap/auth"
|
|
"github.com/hanzoai/world-zap/hub"
|
|
"github.com/hanzoai/world-zap/proto"
|
|
"github.com/hanzoai/world-zap/ratelimit"
|
|
)
|
|
|
|
func mustUpgrade(t *testing.T, w http.ResponseWriter, r *http.Request) *websocket.Conn {
|
|
t.Helper()
|
|
up := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
c, err := up.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
t.Fatalf("upgrade: %v", err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func startSessionServer(t *testing.T, p auth.Principal, h *hub.Hub) (*httptest.Server, context.CancelFunc) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
deps := Deps{
|
|
Hub: h,
|
|
Auth: auth.New(auth.Config{Endpoint: "http://unused"}),
|
|
Limiter: ratelimit.New(),
|
|
Logger: slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})),
|
|
WriteWait: 2 * time.Second,
|
|
PingEvery: time.Hour,
|
|
}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn := mustUpgrade(t, w, r)
|
|
sess := New(conn, p, deps)
|
|
go sess.Run(ctx)
|
|
}))
|
|
return srv, cancel
|
|
}
|
|
|
|
func dial(t *testing.T, base string) *websocket.Conn {
|
|
t.Helper()
|
|
u, _ := url.Parse(base)
|
|
u.Scheme = "ws"
|
|
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func readFrame(t *testing.T, c *websocket.Conn) proto.Frame {
|
|
t.Helper()
|
|
c.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
_, b, err := c.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
f, _, err := proto.Decode(b)
|
|
if err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
return f
|
|
}
|
|
|
|
func writeFrame(t *testing.T, c *websocket.Conn, typ byte, v any) {
|
|
t.Helper()
|
|
b, err := proto.EncodeJSON(typ, v)
|
|
if err != nil {
|
|
t.Fatalf("encode: %v", err)
|
|
}
|
|
if err := c.WriteMessage(websocket.BinaryMessage, b); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSessionInitAck(t *testing.T) {
|
|
h := hub.New(8)
|
|
srv, cancel := startSessionServer(t, auth.Principal{UserID: "u", Org: "hanzo", Plan: "pro"}, h)
|
|
defer srv.Close()
|
|
defer cancel()
|
|
c := dial(t, srv.URL)
|
|
defer c.Close()
|
|
|
|
f := readFrame(t, c)
|
|
if f.Type != proto.TypeINITACK {
|
|
t.Fatalf("want INIT_ACK got %s", proto.TypeName(f.Type))
|
|
}
|
|
var body map[string]any
|
|
_ = json.Unmarshal(f.Payload, &body)
|
|
if body["plan"] != "pro" {
|
|
t.Fatalf("plan: %v", body["plan"])
|
|
}
|
|
}
|
|
|
|
func TestSessionListTopics(t *testing.T) {
|
|
h := hub.New(8)
|
|
srv, cancel := startSessionServer(t, auth.Principal{UserID: "u", Org: "hanzo", Plan: "team"}, h)
|
|
defer srv.Close()
|
|
defer cancel()
|
|
c := dial(t, srv.URL)
|
|
defer c.Close()
|
|
_ = readFrame(t, c) // INIT_ACK
|
|
|
|
writeFrame(t, c, proto.TypeCallTool, map[string]any{
|
|
"id": "1",
|
|
"name": "list_topics",
|
|
"args": map[string]any{},
|
|
})
|
|
f := readFrame(t, c)
|
|
if f.Type != proto.TypeRESOLVE {
|
|
t.Fatalf("want RESOLVE got %s", proto.TypeName(f.Type))
|
|
}
|
|
var resp struct {
|
|
ID string `json:"id"`
|
|
Error bool `json:"error"`
|
|
Content json.RawMessage `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(f.Payload, &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Error || resp.ID != "1" {
|
|
t.Fatalf("unexpected resp: %+v", resp)
|
|
}
|
|
if !strings.Contains(string(resp.Content), "world.events.earthquakes") {
|
|
t.Fatalf("topic list missing earthquakes: %s", resp.Content)
|
|
}
|
|
}
|
|
|
|
func TestSessionSubscribeAndReceivePush(t *testing.T) {
|
|
h := hub.New(8)
|
|
srv, cancel := startSessionServer(t, auth.Principal{UserID: "u", Org: "hanzo", Plan: "pro"}, h)
|
|
defer srv.Close()
|
|
defer cancel()
|
|
c := dial(t, srv.URL)
|
|
defer c.Close()
|
|
_ = readFrame(t, c) // INIT_ACK
|
|
|
|
writeFrame(t, c, proto.TypeCallTool, map[string]any{
|
|
"id": "sub1",
|
|
"name": "subscribe",
|
|
"args": map[string]any{"topic": "world.events.earthquakes"},
|
|
})
|
|
resolve := readFrame(t, c)
|
|
if resolve.Type != proto.TypeRESOLVE {
|
|
t.Fatalf("expected RESOLVE, got %s", proto.TypeName(resolve.Type))
|
|
}
|
|
|
|
// Publish through the hub directly; session should forward.
|
|
_ = h.Publish(hub.Message{
|
|
Topic: "world.events.earthquakes",
|
|
Payload: []byte(`{"id":"usgs:1","mag":4.2}`),
|
|
})
|
|
|
|
// pumpSubs polls every 100ms, so we allow up to a second.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
c.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
|
_, b, err := c.ReadMessage()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
f, _, err := proto.Decode(b)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if f.Type == proto.TypePUSH {
|
|
if !strings.Contains(string(f.Payload), "usgs:1") {
|
|
t.Fatalf("unexpected push payload: %s", f.Payload)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("no PUSH received in time")
|
|
}
|
|
|
|
func TestSessionSubscribePlanTooLow(t *testing.T) {
|
|
h := hub.New(8)
|
|
srv, cancel := startSessionServer(t, auth.Principal{UserID: "u", Org: "acme", Plan: "free"}, h)
|
|
defer srv.Close()
|
|
defer cancel()
|
|
c := dial(t, srv.URL)
|
|
defer c.Close()
|
|
_ = readFrame(t, c) // INIT_ACK
|
|
|
|
writeFrame(t, c, proto.TypeCallTool, map[string]any{
|
|
"id": "x",
|
|
"name": "subscribe",
|
|
"args": map[string]any{"topic": "world.ships.ais"}, // requires team
|
|
})
|
|
f := readFrame(t, c)
|
|
if f.Type != proto.TypeRESOLVE {
|
|
t.Fatalf("want RESOLVE got %s", proto.TypeName(f.Type))
|
|
}
|
|
if !strings.Contains(string(f.Payload), "plan_too_low") {
|
|
t.Fatalf("expected plan_too_low error: %s", f.Payload)
|
|
}
|
|
}
|
|
|
|
func TestSessionAdminCanPublish(t *testing.T) {
|
|
h := hub.New(8)
|
|
srv, cancel := startSessionServer(t, auth.Principal{UserID: "admin", Org: "hanzo", Plan: "enterprise", IsAdmin: true}, h)
|
|
defer srv.Close()
|
|
defer cancel()
|
|
c := dial(t, srv.URL)
|
|
defer c.Close()
|
|
_ = readFrame(t, c) // INIT_ACK
|
|
|
|
// Subscribe first to verify publish reaches subscribers.
|
|
writeFrame(t, c, proto.TypeCallTool, map[string]any{
|
|
"id": "s", "name": "subscribe",
|
|
"args": map[string]any{"topic": "world.news.live"},
|
|
})
|
|
_ = readFrame(t, c)
|
|
|
|
writeFrame(t, c, proto.TypeCallTool, map[string]any{
|
|
"id": "p", "name": "publish",
|
|
"args": map[string]any{"topic": "world.news.live", "payload": map[string]any{"headline": "test"}},
|
|
})
|
|
// Expect ack RESOLVE + at some point a PUSH.
|
|
gotPush := false
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) && !gotPush {
|
|
c.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
|
|
_, b, err := c.ReadMessage()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
f, _, err := proto.Decode(b)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if f.Type == proto.TypePUSH {
|
|
gotPush = true
|
|
}
|
|
}
|
|
if !gotPush {
|
|
t.Fatalf("admin publish did not fan out")
|
|
}
|
|
}
|
|
|
|
func TestSessionRejectsNonAdminPublish(t *testing.T) {
|
|
h := hub.New(8)
|
|
srv, cancel := startSessionServer(t, auth.Principal{UserID: "u", Org: "acme", Plan: "enterprise"}, h)
|
|
defer srv.Close()
|
|
defer cancel()
|
|
c := dial(t, srv.URL)
|
|
defer c.Close()
|
|
_ = readFrame(t, c) // INIT_ACK
|
|
|
|
writeFrame(t, c, proto.TypeCallTool, map[string]any{
|
|
"id": "p", "name": "publish",
|
|
"args": map[string]any{"topic": "world.news.live", "payload": map[string]any{}},
|
|
})
|
|
f := readFrame(t, c)
|
|
if !strings.Contains(string(f.Payload), "forbidden") {
|
|
t.Fatalf("expected forbidden: %s", f.Payload)
|
|
}
|
|
}
|