- 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>
339 lines
10 KiB
Go
339 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
"github.com/synapbus/synapbus/internal/messaging"
|
|
"github.com/synapbus/synapbus/internal/storage"
|
|
)
|
|
|
|
func newTestDBFull(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)
|
|
}
|
|
|
|
// Seed test users
|
|
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (1, 'owner1', 'hash', 'Owner 1')`)
|
|
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (2, 'owner2', 'hash', 'Owner 2')`)
|
|
|
|
return db
|
|
}
|
|
|
|
func seedTestAgent(t *testing.T, db *sql.DB, name 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 (?, ?, 'ai', '{}', ?, 'testhash', 'active')`,
|
|
name, name, ownerID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("seed agent %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
func seedTestMessage(t *testing.T, db *sql.DB, from, to, body string, priority int) int64 {
|
|
t.Helper()
|
|
// Ensure a conversation exists
|
|
var convID int64
|
|
result, err := db.Exec(
|
|
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
|
"test", from,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("seed conversation: %v", err)
|
|
}
|
|
convID, _ = result.LastInsertId()
|
|
|
|
result, err = db.Exec(
|
|
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'pending', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
|
convID, from, to, body, priority,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("seed message: %v", err)
|
|
}
|
|
id, _ := result.LastInsertId()
|
|
return id
|
|
}
|
|
|
|
func seedDeadLetter(t *testing.T, db *sql.DB, ownerID int64, toAgent, fromAgent, body string, priority int) int64 {
|
|
t.Helper()
|
|
result, err := db.Exec(
|
|
`INSERT INTO dead_letters (owner_id, original_message_id, to_agent, from_agent, body, subject, priority, metadata) VALUES (?, 0, ?, ?, ?, '', ?, '{}')`,
|
|
ownerID, toAgent, fromAgent, body, priority,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("seed dead letter: %v", err)
|
|
}
|
|
id, _ := result.LastInsertId()
|
|
return id
|
|
}
|
|
|
|
func makeDeadLetterRequest(t *testing.T, handler http.Handler, method, path string, ownerID string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, path, nil)
|
|
if ownerID != "" {
|
|
req.Header.Set("X-Owner-ID", ownerID)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
func TestDeadLetterCaptureOnAgentDeletion(t *testing.T) {
|
|
db := newTestDBFull(t)
|
|
|
|
seedTestAgent(t, db, "sender", 1)
|
|
seedTestAgent(t, db, "target-bot", 1)
|
|
|
|
// Send pending messages to target-bot
|
|
seedTestMessage(t, db, "sender", "target-bot", "Hello, are you there?", 5)
|
|
seedTestMessage(t, db, "sender", "target-bot", "Urgent task for you", 8)
|
|
|
|
// Create dead letter store and capture
|
|
dls := messaging.NewDeadLetterStore(db)
|
|
|
|
captured, err := dls.CaptureDeadLetters(context.Background(), 1, "target-bot")
|
|
if err != nil {
|
|
t.Fatalf("CaptureDeadLetters: %v", err)
|
|
}
|
|
if captured != 2 {
|
|
t.Errorf("captured = %d, want 2", captured)
|
|
}
|
|
|
|
// Verify dead letters were stored
|
|
letters, total, err := dls.ListDeadLetters(context.Background(), 1, false, 50)
|
|
if err != nil {
|
|
t.Fatalf("ListDeadLetters: %v", err)
|
|
}
|
|
if total != 2 {
|
|
t.Errorf("total = %d, want 2", total)
|
|
}
|
|
if len(letters) != 2 {
|
|
t.Fatalf("len(letters) = %d, want 2", len(letters))
|
|
}
|
|
|
|
// Verify dead letter data
|
|
foundHello := false
|
|
foundUrgent := false
|
|
for _, dl := range letters {
|
|
if dl.ToAgent != "target-bot" {
|
|
t.Errorf("to_agent = %q, want target-bot", dl.ToAgent)
|
|
}
|
|
if dl.FromAgent != "sender" {
|
|
t.Errorf("from_agent = %q, want sender", dl.FromAgent)
|
|
}
|
|
if dl.OwnerID != 1 {
|
|
t.Errorf("owner_id = %d, want 1", dl.OwnerID)
|
|
}
|
|
if dl.Body == "Hello, are you there?" {
|
|
foundHello = true
|
|
}
|
|
if dl.Body == "Urgent task for you" {
|
|
foundUrgent = true
|
|
if dl.Priority != 8 {
|
|
t.Errorf("priority = %d, want 8", dl.Priority)
|
|
}
|
|
}
|
|
}
|
|
if !foundHello {
|
|
t.Error("expected to find 'Hello, are you there?' dead letter")
|
|
}
|
|
if !foundUrgent {
|
|
t.Error("expected to find 'Urgent task for you' dead letter")
|
|
}
|
|
}
|
|
|
|
func TestDeadLetterListAPI(t *testing.T) {
|
|
db := newTestDBFull(t)
|
|
dls := messaging.NewDeadLetterStore(db)
|
|
|
|
// Seed dead letters for owner 1
|
|
seedDeadLetter(t, db, 1, "deleted-bot", "sender", "Message 1", 5)
|
|
seedDeadLetter(t, db, 1, "deleted-bot", "sender", "Message 2", 3)
|
|
// Seed one for owner 2 (should not appear)
|
|
seedDeadLetter(t, db, 2, "other-bot", "other-sender", "Other message", 5)
|
|
|
|
deadLettersHandler := NewDeadLettersHandler(dls)
|
|
|
|
// Build router with dead letters handler
|
|
router := NewRouter(nil, nil, nil)
|
|
router.Group(func(r chi.Router) {
|
|
r.Use(OwnerAuthMiddleware)
|
|
r.Get("/api/dead-letters", deadLettersHandler.List)
|
|
r.Get("/api/dead-letters/count", deadLettersHandler.Count)
|
|
r.Post("/api/dead-letters/{id}/acknowledge", deadLettersHandler.Acknowledge)
|
|
})
|
|
|
|
t.Run("list returns owner dead letters", func(t *testing.T) {
|
|
rr := makeDeadLetterRequest(t, router, "GET", "/api/dead-letters", "1")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
DeadLetters []messaging.DeadLetter `json:"dead_letters"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(resp.DeadLetters) != 2 {
|
|
t.Errorf("got %d dead letters, want 2", len(resp.DeadLetters))
|
|
}
|
|
if resp.Total != 2 {
|
|
t.Errorf("total = %d, want 2", resp.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("owner isolation", func(t *testing.T) {
|
|
rr := makeDeadLetterRequest(t, router, "GET", "/api/dead-letters", "2")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rr.Code)
|
|
}
|
|
|
|
var resp struct {
|
|
DeadLetters []messaging.DeadLetter `json:"dead_letters"`
|
|
Total int `json:"total"`
|
|
}
|
|
json.Unmarshal(rr.Body.Bytes(), &resp)
|
|
if len(resp.DeadLetters) != 1 {
|
|
t.Errorf("got %d dead letters, want 1", len(resp.DeadLetters))
|
|
}
|
|
})
|
|
|
|
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
|
rr := makeDeadLetterRequest(t, router, "GET", "/api/dead-letters", "")
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDeadLetterAcknowledgeAPI(t *testing.T) {
|
|
db := newTestDBFull(t)
|
|
dls := messaging.NewDeadLetterStore(db)
|
|
|
|
dlID := seedDeadLetter(t, db, 1, "deleted-bot", "sender", "Unread message", 5)
|
|
|
|
deadLettersHandler := NewDeadLettersHandler(dls)
|
|
|
|
router := NewRouter(nil, nil, nil)
|
|
router.Group(func(r chi.Router) {
|
|
r.Use(OwnerAuthMiddleware)
|
|
r.Get("/api/dead-letters", deadLettersHandler.List)
|
|
r.Get("/api/dead-letters/count", deadLettersHandler.Count)
|
|
r.Post("/api/dead-letters/{id}/acknowledge", deadLettersHandler.Acknowledge)
|
|
})
|
|
|
|
t.Run("acknowledge dead letter", func(t *testing.T) {
|
|
rr := makeDeadLetterRequest(t, router, "POST", fmt.Sprintf("/api/dead-letters/%d/acknowledge", dlID), "1")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
json.Unmarshal(rr.Body.Bytes(), &resp)
|
|
if resp["acknowledged"] != true {
|
|
t.Errorf("acknowledged = %v, want true", resp["acknowledged"])
|
|
}
|
|
})
|
|
|
|
t.Run("acknowledged dead letter not in default list", func(t *testing.T) {
|
|
rr := makeDeadLetterRequest(t, router, "GET", "/api/dead-letters", "1")
|
|
var resp struct {
|
|
DeadLetters []messaging.DeadLetter `json:"dead_letters"`
|
|
}
|
|
json.Unmarshal(rr.Body.Bytes(), &resp)
|
|
if len(resp.DeadLetters) != 0 {
|
|
t.Errorf("got %d dead letters, want 0 (acknowledged should be hidden)", len(resp.DeadLetters))
|
|
}
|
|
})
|
|
|
|
t.Run("acknowledged dead letter visible when including acknowledged", func(t *testing.T) {
|
|
rr := makeDeadLetterRequest(t, router, "GET", "/api/dead-letters?acknowledged=true", "1")
|
|
var resp struct {
|
|
DeadLetters []messaging.DeadLetter `json:"dead_letters"`
|
|
}
|
|
json.Unmarshal(rr.Body.Bytes(), &resp)
|
|
if len(resp.DeadLetters) != 1 {
|
|
t.Errorf("got %d dead letters, want 1", len(resp.DeadLetters))
|
|
}
|
|
})
|
|
|
|
t.Run("wrong owner cannot acknowledge", func(t *testing.T) {
|
|
// Seed another dead letter for owner 1
|
|
newID := seedDeadLetter(t, db, 1, "deleted-bot", "sender", "Another message", 5)
|
|
rr := makeDeadLetterRequest(t, router, "POST", fmt.Sprintf("/api/dead-letters/%d/acknowledge", newID), "2")
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDeadLetterCountAPI(t *testing.T) {
|
|
db := newTestDBFull(t)
|
|
dls := messaging.NewDeadLetterStore(db)
|
|
|
|
seedDeadLetter(t, db, 1, "deleted-bot", "sender", "Message 1", 5)
|
|
seedDeadLetter(t, db, 1, "deleted-bot", "sender", "Message 2", 3)
|
|
|
|
deadLettersHandler := NewDeadLettersHandler(dls)
|
|
|
|
router := NewRouter(nil, nil, nil)
|
|
router.Group(func(r chi.Router) {
|
|
r.Use(OwnerAuthMiddleware)
|
|
r.Get("/api/dead-letters/count", deadLettersHandler.Count)
|
|
r.Post("/api/dead-letters/{id}/acknowledge", deadLettersHandler.Acknowledge)
|
|
})
|
|
|
|
t.Run("count returns unacknowledged count", func(t *testing.T) {
|
|
rr := makeDeadLetterRequest(t, router, "GET", "/api/dead-letters/count", "1")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rr.Code)
|
|
}
|
|
|
|
var resp map[string]any
|
|
json.Unmarshal(rr.Body.Bytes(), &resp)
|
|
if resp["count"].(float64) != 2 {
|
|
t.Errorf("count = %v, want 2", resp["count"])
|
|
}
|
|
})
|
|
|
|
t.Run("count decreases after acknowledge", func(t *testing.T) {
|
|
// Get the first dead letter's ID
|
|
var dlID int64
|
|
db.QueryRow(`SELECT id FROM dead_letters WHERE owner_id = 1 LIMIT 1`).Scan(&dlID)
|
|
|
|
makeDeadLetterRequest(t, router, "POST", fmt.Sprintf("/api/dead-letters/%d/acknowledge", dlID), "1")
|
|
|
|
rr := makeDeadLetterRequest(t, router, "GET", "/api/dead-letters/count", "1")
|
|
var resp map[string]any
|
|
json.Unmarshal(rr.Body.Bytes(), &resp)
|
|
if resp["count"].(float64) != 1 {
|
|
t.Errorf("count = %v, want 1", resp["count"])
|
|
}
|
|
})
|
|
}
|