Files
synapbus/internal/api/deadletters_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

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"])
}
})
}