Files
synapbus/internal/api/messages_handler_test.go
T
Algis DumbrisandClaude Opus 4.6 2575ce2626 feat: OAuth 2.1 with PKCE, MCP auth, dead letters, channel management, and UX polish
- Add OAuth 2.1 identity provider with PKCE S256 (ory/fosite)
- Add RFC 7591 dynamic client registration for MCP clients
- Add RFC 8414 OAuth metadata discovery endpoint
- Add branded OAuth login/authorize pages with SynapBus design
- Add SYNAPBUS_BASE_URL env var for remote/LAN deployments
- Add OAuth bearer token authentication for MCP connections
- Add dead letter queue with Web UI management page
- Add channel leave, member list, and improved channel management
- Add agent auth middleware for MCP-authenticated requests
- Add console printer for structured server startup output
- Hide human accounts from agent management UI
- Fix SSE through middleware (Flush/Unwrap support)
- Fix graceful shutdown by closing SSE clients before server stop
- Fix localhost/127.0.0.1 redirect URI normalization for OAuth
- Remove agent self-registration MCP tools (manage via Web UI only)
- Update README with OAuth setup guide and MCP client config example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 12:54:02 +02:00

169 lines
5.2 KiB
Go

package api
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/auth"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/storage"
"github.com/synapbus/synapbus/internal/trace"
)
func newMessagesTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
t.Fatalf("enable foreign keys: %v", err)
}
ctx := context.Background()
if err := storage.RunMigrations(ctx, db); err != nil {
t.Fatalf("run migrations: %v", err)
}
return db
}
func seedUser(t *testing.T, db *sql.DB, id int64, username string) {
t.Helper()
_, err := db.Exec(
`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (?, ?, 'hash', ?)`,
id, username, username,
)
if err != nil {
t.Fatalf("seed user %s: %v", username, err)
}
}
func seedTestAgentWithType(t *testing.T, db *sql.DB, name, agentType string, ownerID int64) {
t.Helper()
_, err := db.Exec(
`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status)
VALUES (?, ?, ?, '{}', ?, 'testhash', 'active')`,
name, name, agentType, ownerID,
)
if err != nil {
t.Fatalf("seed agent %s: %v", name, err)
}
}
func TestSendMessage_SessionAuth_OverridesFrom(t *testing.T) {
db := newMessagesTestDB(t)
// Create users and agents
seedUser(t, db, 1, "alice")
seedUser(t, db, 2, "bob")
seedTestAgentWithType(t, db, "alice", "human", 1) // Human agent for alice
seedTestAgentWithType(t, db, "alice-bot", "ai", 1) // AI agent for alice
seedTestAgentWithType(t, db, "bob", "human", 2) // Need bob for messaging target
agentStore := agents.NewSQLiteAgentStore(db)
tracer := trace.NewTracer(db)
t.Cleanup(func() { tracer.Close() })
agentService := agents.NewAgentService(agentStore, tracer)
msgStore := messaging.NewSQLiteMessageStore(db)
msgService := messaging.NewMessagingService(msgStore, tracer)
handler := NewMessagesHandler(msgService, agentService)
t.Run("session auth overrides from with human agent", func(t *testing.T) {
// Send a message with from="alice-bot" but session auth should override to "alice"
body := `{"from":"alice-bot","to":"bob","body":"Hello from UI"}`
req := httptest.NewRequest("POST", "/api/messages", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// Set up session auth context: set ownerID and session ID
ctx := ContextWithOwnerID(req.Context(), 1)
ctx = auth.ContextWithSessionID(ctx, "test-session-id")
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
handler.SendMessage(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusCreated, rr.Body.String())
}
var msg map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &msg); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
// Verify the from_agent was overridden to the human agent
if msg["from_agent"] != "alice" {
t.Errorf("from_agent = %v, want 'alice' (human agent)", msg["from_agent"])
}
})
t.Run("session auth without from field uses human agent", func(t *testing.T) {
body := `{"to":"bob","body":"Hello without from"}`
req := httptest.NewRequest("POST", "/api/messages", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx := ContextWithOwnerID(req.Context(), 1)
ctx = auth.ContextWithSessionID(ctx, "test-session-id")
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
handler.SendMessage(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusCreated, rr.Body.String())
}
var msg map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &msg); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if msg["from_agent"] != "alice" {
t.Errorf("from_agent = %v, want 'alice' (human agent)", msg["from_agent"])
}
})
t.Run("non-session auth respects from field", func(t *testing.T) {
// Without session ID in context, the from field should be used as-is
body := `{"from":"alice-bot","to":"bob","body":"Hello from API"}`
req := httptest.NewRequest("POST", "/api/messages", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// Only set ownerID (no session ID) — simulates API key auth
ctx := ContextWithOwnerID(req.Context(), 1)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
handler.SendMessage(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusCreated, rr.Body.String())
}
var msg map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &msg); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
// Should use the provided from field since it's not a session auth
if msg["from_agent"] != "alice-bot" {
t.Errorf("from_agent = %v, want 'alice-bot'", msg["from_agent"])
}
})
}