Phase 0 of plans/2026-07-12-wails-desktop-app.md. The control-room SPA is no longer embedded (web/embed.go deleted); it's a standalone static build served separately (make ui / make deploy-ui). The api process adds CORS and drops the dev-open auth bypass — every route now needs a real bearer token, including SSE (?token= query param, EventSource can't set headers) and api's own /agent proxy to nomos (previously unauthenticated by omission). nomos was an unauthenticated client of api's /mcp and approval-decision endpoints; closing dev-open would have broken it, so it now sends Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN on every call back to api. SPA gets a runtime config module (config.ts) and a Config.svelte first-launch/reconfigure page, reachable afterwards via a "Connection" entry in the sidebar footer. Every fetch() in api.ts routes through fetchWithAuth so the same build works same-origin (browser prod, Vite dev proxy) or cross-origin (future Wails webview, remote access). Six gaps found against the plan and the live Caddy topology while implementing — documented in the plan's "Plan review" section, most notably: api's own /agent mount was never behind combinedAuth (fixed), and production's Authentik forward-auth needs a bearer-token bypass for API routes that this repo's Caddyfile.oikos reference copy now has, but the real dtoro/caddy-conf deploy does not yet. Verified live: cross-origin static SPA + API, CORS, bearer auth, SSE query-token auth, and localStorage persistence all confirmed working in-browser. Full Go test suite and npm run build pass with no regressions against the pre-change baseline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
94 lines
3.1 KiB
Go
94 lines
3.1 KiB
Go
package httpapi
|
|
|
|
// Real-connection SSE test. httptest.NewRecorder buffers and never flushes,
|
|
// so this uses httptest.NewServer + a streaming client to verify that:
|
|
// - the raw serveSSE handler (not the generated 501 stub) serves the route,
|
|
// - an event created *after* the client connects is delivered in real time
|
|
// (i.e. flushed before the connection closes),
|
|
// - the SSE `data:` payload is the canonical gen.Event shape.
|
|
// Guarded by OIKOS_TEST_DATABASE_URL.
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSSEStreamRealtimeDelivery(t *testing.T) {
|
|
srv := httptest.NewServer(newTestHandler(t, devConfig()))
|
|
defer srv.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// Connect to the stream.
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL+"/api/v1/events/stream", nil)
|
|
req.Header.Set("Authorization", "Bearer "+testAuthToken)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("connect stream: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("stream status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
|
t.Fatalf("content-type = %q, want text/event-stream", ct)
|
|
}
|
|
|
|
// Read SSE frames in a goroutine.
|
|
dataCh := make(chan map[string]any, 4)
|
|
go func() {
|
|
sc := bufio.NewScanner(resp.Body)
|
|
for sc.Scan() {
|
|
line := sc.Text()
|
|
if strings.HasPrefix(line, "data: ") {
|
|
var m map[string]any
|
|
if json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &m) == nil {
|
|
dataCh <- m
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Give the subscriber a moment to register, then trigger an event by
|
|
// POSTing to the SAME live server (same DB → NOTIFY the listener sees).
|
|
time.Sleep(300 * time.Millisecond)
|
|
payload, _ := json.Marshal(map[string]any{"slug": "service:sse-rt", "type": "service", "name": "sse-rt"})
|
|
createReq, _ := http.NewRequestWithContext(ctx, "POST", srv.URL+"/api/v1/entities", bytes.NewReader(payload))
|
|
createReq.Header.Set("Content-Type", "application/json")
|
|
createReq.Header.Set("Authorization", "Bearer "+testAuthToken)
|
|
cResp, err := http.DefaultClient.Do(createReq)
|
|
if err != nil {
|
|
t.Fatalf("trigger create: %v", err)
|
|
}
|
|
cResp.Body.Close()
|
|
if cResp.StatusCode != 201 {
|
|
t.Fatalf("trigger create status = %d, want 201", cResp.StatusCode)
|
|
}
|
|
|
|
// The event must arrive in real time (well before the 10s ctx deadline),
|
|
// proving the handler flushes rather than buffering until close.
|
|
select {
|
|
case ev := <-dataCh:
|
|
if ev["type"] != "entity.created" {
|
|
t.Errorf("event type = %v, want entity.created", ev["type"])
|
|
}
|
|
// canonical shape: snake_case + decoded data object
|
|
if _, ok := ev["entity_id"]; !ok {
|
|
t.Errorf("missing snake_case entity_id: %v", ev)
|
|
}
|
|
if d, ok := ev["data"].(map[string]any); !ok || d["slug"] != "service:sse-rt" {
|
|
t.Errorf("data not a decoded object with slug: %v", ev["data"])
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("SSE event not delivered within 3s (flushing broken?)")
|
|
}
|
|
}
|