Files
kanban/e2e/chat_ws_e2e_test.go
egutierrez 69ebc1e59b fix(chat): vite proxy ws + e2e tests para chat WebSocket
- frontend/vite.config.ts: anadir ws: true al proxy de /api para que el
  dev server (5180) reenvie WebSocket upgrade al backend (8095). Sin esto
  Firefox da "websocket error" al abrir /api/chat/ws en modo dev.
- e2e/chat_ws_e2e_test.go: 4 tests nuevos que arrancan el binario kanban
  en puerto efimero con un fake claude (bash script que emite NDJSON), se
  loguean via /api/auth/login y dialean /api/chat/ws con cookie de sesion.
  Verifican: deltas + done, tool_use + tool_result + board_changed,
  rechazo sin sesion, /api/tool sin token = 401.
- e2e/go.mod: anade nhooyr.io/websocket (cliente WS para tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:00:38 +02:00

354 lines
11 KiB
Go

// E2E test que arranca el binario kanban en un puerto efimero con un fake
// claude script y verifica que /api/chat/ws emite los eventos esperados.
//
// Cubre el bug del proxy Vite (ws: true) tambien en el camino directo: la
// conexion WebSocket se establece contra el puerto del backend y atraviesa
// el upgrade real de nhooyr.io/websocket.
//
// Ejecucion:
// cd apps/kanban/e2e && go test -v -run TestE2E_ChatWS ./...
//
// Build artifacts: el test compila el binario kanban en un dir temporal.
// El fake claude tambien se escribe en ese dir.
package e2e
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"nhooyr.io/websocket"
)
// buildKanbanBinary compila el backend en un binario temporal y retorna su
// path. Se compila una vez por test.
func buildKanbanBinary(t *testing.T) string {
t.Helper()
dir := t.TempDir()
bin := filepath.Join(dir, "kanban")
cmd := exec.Command("go", "build", "-tags", "fts5", "-o", bin, ".")
cmd.Dir = "../backend"
cmd.Env = append(os.Environ(), "CGO_ENABLED=1")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go build kanban: %v\n%s", err, out)
}
return bin
}
// fakeClaudeScript writes a bash script that emits NDJSON stream-json events
// and exits 0.
func fakeClaudeBin(t *testing.T, payload string) string {
t.Helper()
if _, err := os.Stat("/bin/bash"); err != nil {
t.Skip("/bin/bash not available")
}
dir := t.TempDir()
path := filepath.Join(dir, "claude")
body := "#!/bin/bash\nset -e\ncat <<'__EOF__'\n" + payload + "\n__EOF__\n"
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
t.Fatalf("write fake claude: %v", err)
}
return path
}
// freePort returns a random unused TCP port.
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// kanbanLauncher starts kanban on a random port with a fake claude binary
// and a fresh DB. Returns the URL and a cleanup func.
type kanbanLauncher struct {
URL string
Port int
DBDir string
Cancel context.CancelFunc
Stderr *bytes.Buffer
Stdout *bytes.Buffer
}
func startKanban(t *testing.T, fakeClaude string) *kanbanLauncher {
t.Helper()
bin := buildKanbanBinary(t)
dbDir := t.TempDir()
dbPath := filepath.Join(dbDir, "kanban.db")
port := freePort(t)
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, bin,
"--port", fmt.Sprint(port),
"--db", dbPath,
"--initial-admin", "e2e:e2etest",
)
cmd.Env = append(os.Environ(),
"KANBAN_CLAUDE_BIN="+fakeClaude,
"KANBAN_LISTEN_PORT="+fmt.Sprint(port),
"KANBAN_INITIAL_ADMIN=e2e:e2etest",
)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
cancel()
t.Fatalf("start kanban: %v", err)
}
l := &kanbanLauncher{
URL: fmt.Sprintf("http://127.0.0.1:%d", port),
Port: port,
DBDir: dbDir,
Cancel: cancel,
Stdout: stdout,
Stderr: stderr,
}
// Espera a que el server responda /api/board (publico-ish: 401 sin sesion).
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
resp, err := http.Get(l.URL + "/api/board")
if err == nil {
resp.Body.Close()
t.Cleanup(func() {
cancel()
_ = cmd.Wait()
if t.Failed() {
t.Logf("kanban stdout:\n%s", stdout.String())
t.Logf("kanban stderr:\n%s", stderr.String())
}
})
return l
}
time.Sleep(150 * time.Millisecond)
}
cancel()
_ = cmd.Wait()
t.Fatalf("kanban no respondio en 15s\nstdout:\n%s\nstderr:\n%s", stdout.String(), stderr.String())
return nil
}
// loginGetCookie performs API login and returns the cookie jar with session.
func loginGetCookie(t *testing.T, baseURL string) http.CookieJar {
t.Helper()
jar, _ := cookiejar.New(nil)
cli := &http.Client{Jar: jar, Timeout: 5 * time.Second}
body, _ := json.Marshal(map[string]string{"username": "e2e", "password": "e2etest"})
resp, err := cli.Post(baseURL+"/api/auth/login", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("login: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
buf, _ := io.ReadAll(resp.Body)
t.Fatalf("login status %d: %s", resp.StatusCode, buf)
}
return jar
}
// dialWS opens a WebSocket using the session cookie from the jar.
func dialWS(t *testing.T, baseURL string, jar http.CookieJar) *websocket.Conn {
t.Helper()
u, _ := url.Parse(baseURL)
wsURL := "ws://" + u.Host + "/api/chat/ws"
hdr := http.Header{}
for _, c := range jar.Cookies(u) {
hdr.Add("Cookie", c.Name+"="+c.Value)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: hdr})
if err != nil {
t.Fatalf("ws dial %s: %v", wsURL, err)
}
return conn
}
type wsMsg struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Tool string `json:"tool,omitempty"`
ToolID string `json:"tool_id,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
Result string `json:"result,omitempty"`
IsError bool `json:"is_error,omitempty"`
BoardChanged bool `json:"board_changed,omitempty"`
Error string `json:"error,omitempty"`
}
func readMsg(t *testing.T, conn *websocket.Conn) wsMsg {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, data, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read: %v", err)
}
var m wsMsg
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("unmarshal %q: %v", string(data), err)
}
return m
}
func writeMsg(t *testing.T, conn *websocket.Conn, payload any) {
t.Helper()
body, _ := json.Marshal(payload)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := conn.Write(ctx, websocket.MessageText, body); err != nil {
t.Fatalf("write: %v", err)
}
}
// --- Tests ----------------------------------------------------------------
// TestE2E_ChatWS_StreamsTextAndCloses arranca el backend con fake claude
// que emite un mensaje de texto + result, abre WebSocket via cookie de
// sesion y comprueba que llegan deltas + done.
func TestE2E_ChatWS_StreamsTextAndCloses(t *testing.T) {
payload := `{"type":"system","subtype":"init","session_id":"s1","model":"test"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hola "}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"mundo"}]}}
{"type":"result","subtype":"success","is_error":false,"result":"Hola mundo","stop_reason":"end_turn"}`
srv := startKanban(t, fakeClaudeBin(t, payload))
jar := loginGetCookie(t, srv.URL)
conn := dialWS(t, srv.URL, jar)
defer conn.Close(websocket.StatusNormalClosure, "")
writeMsg(t, conn, map[string]any{
"messages": []map[string]string{{"role": "user", "content": "saluda"}},
})
var deltas []string
var sawDone bool
for i := 0; i < 12 && !sawDone; i++ {
m := readMsg(t, conn)
switch m.Type {
case "delta":
deltas = append(deltas, m.Text)
case "done":
sawDone = true
case "error":
t.Fatalf("error event: %s", m.Error)
}
}
if !sawDone {
t.Fatalf("never received done event")
}
if got := strings.Join(deltas, ""); got != "Hola mundo" {
t.Fatalf("expected 'Hola mundo', got %q", got)
}
}
// TestE2E_ChatWS_ToolUseFlow comprueba que tool_use + tool_result se
// reenvian correctamente y que board_changed=true cuando la tool muta.
func TestE2E_ChatWS_ToolUseFlow(t *testing.T) {
payload := `{"type":"system","subtype":"init"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"mcp__kanban__create_column","input":{"name":"Backlog"}}]}}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"{\"ok\":true,\"result\":{\"id\":\"col_x\"}}","is_error":false}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Listo"}]}}
{"type":"result","subtype":"success","is_error":false,"result":"Listo","stop_reason":"end_turn"}`
srv := startKanban(t, fakeClaudeBin(t, payload))
jar := loginGetCookie(t, srv.URL)
conn := dialWS(t, srv.URL, jar)
defer conn.Close(websocket.StatusNormalClosure, "")
writeMsg(t, conn, map[string]any{
"messages": []map[string]string{{"role": "user", "content": "crea Backlog"}},
})
var sawToolUse, sawToolResult, sawDelta, sawDone, boardChanged bool
var toolName string
for i := 0; i < 16 && !sawDone; i++ {
m := readMsg(t, conn)
switch m.Type {
case "tool_use":
sawToolUse = true
toolName = m.Tool
if !strings.Contains(string(m.Input), "Backlog") {
t.Errorf("input missing Backlog: %s", m.Input)
}
case "tool_result":
sawToolResult = true
case "delta":
sawDelta = true
case "done":
sawDone = true
boardChanged = m.BoardChanged
case "error":
t.Fatalf("error: %s", m.Error)
}
}
if !sawToolUse || !sawToolResult || !sawDelta || !sawDone {
t.Fatalf("missing events: tool_use=%v tool_result=%v delta=%v done=%v",
sawToolUse, sawToolResult, sawDelta, sawDone)
}
if toolName != "create_column" {
t.Errorf("expected tool stripped to create_column, got %q", toolName)
}
if !boardChanged {
t.Errorf("expected board_changed=true")
}
}
// TestE2E_ChatWS_RejectsUnauthenticated: sin cookie de sesion el upgrade
// debe fallar con 401 (auth middleware /api/chat/ws).
func TestE2E_ChatWS_RejectsUnauthenticated(t *testing.T) {
payload := `{"type":"result","subtype":"success","is_error":false,"result":""}`
srv := startKanban(t, fakeClaudeBin(t, payload))
u, _ := url.Parse(srv.URL)
wsURL := "ws://" + u.Host + "/api/chat/ws"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _, err := websocket.Dial(ctx, wsURL, nil)
if err == nil {
t.Fatalf("expected dial to fail without cookie")
}
if !strings.Contains(err.Error(), "401") {
t.Fatalf("expected 401 in error, got: %v", err)
}
}
// TestE2E_InternalToolEndpoint_RealBackend: comprueba que el endpoint
// /api/tool/{name} esta vivo y rechaza tokens invalidos. Sirve para
// confirmar que la auth bypass + token check funciona en el binario
// real (no solo en httptest).
func TestE2E_InternalToolEndpoint_RealBackend(t *testing.T) {
payload := `{"type":"result","is_error":false,"result":""}`
srv := startKanban(t, fakeClaudeBin(t, payload))
// Sin token -> 401
req, _ := http.NewRequest("POST", srv.URL+"/api/tool/list_board", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
resp.Body.Close()
if resp.StatusCode != 401 {
t.Fatalf("expected 401 without token, got %d", resp.StatusCode)
}
}