744 lines
25 KiB
Go
744 lines
25 KiB
Go
package websocket
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/kandev/kandev/internal/auth/authn"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
githubsvc "github.com/kandev/kandev/internal/github"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
ws "github.com/kandev/kandev/pkg/websocket"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func testLogger() *logger.Logger {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
|
|
return log
|
|
}
|
|
|
|
type subscriptionRecordingEventBus struct {
|
|
*bus.MemoryEventBus
|
|
subjects []string
|
|
}
|
|
|
|
type recordingSubscription struct{}
|
|
|
|
func (recordingSubscription) Unsubscribe() error { return nil }
|
|
func (recordingSubscription) IsValid() bool { return true }
|
|
|
|
type queuedTransportSubscription struct {
|
|
subject string
|
|
handler bus.EventHandler
|
|
events []*bus.Event
|
|
valid bool
|
|
}
|
|
|
|
func (s *queuedTransportSubscription) Unsubscribe() error {
|
|
s.valid = false
|
|
return nil
|
|
}
|
|
|
|
func (s *queuedTransportSubscription) IsValid() bool { return s.valid }
|
|
|
|
// queuedTransportEventBus models NATS' independent per-subscription queues.
|
|
// Tests can schedule one subject's subscription ahead of another while each
|
|
// subscription still preserves FIFO delivery internally.
|
|
type queuedTransportEventBus struct {
|
|
*bus.MemoryEventBus
|
|
subscriptions []*queuedTransportSubscription
|
|
}
|
|
|
|
func (b *queuedTransportEventBus) Subscribe(
|
|
subject string,
|
|
handler bus.EventHandler,
|
|
) (bus.Subscription, error) {
|
|
subscription := &queuedTransportSubscription{subject: subject, handler: handler, valid: true}
|
|
b.subscriptions = append(b.subscriptions, subscription)
|
|
return subscription, nil
|
|
}
|
|
|
|
func (b *queuedTransportEventBus) Publish(_ context.Context, subject string, event *bus.Event) error {
|
|
event.Subject = subject
|
|
for _, subscription := range b.subscriptions {
|
|
if subscription.valid && (subscription.subject == subject || subscription.subject == ">") {
|
|
subscription.events = append(subscription.events, event)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *queuedTransportEventBus) deliverSubjectFirst(ctx context.Context, subject string) error {
|
|
for _, subscription := range b.subscriptions {
|
|
if subscription.subject != subject || len(subscription.events) == 0 {
|
|
continue
|
|
}
|
|
event := subscription.events[0]
|
|
subscription.events = subscription.events[1:]
|
|
return subscription.handler(ctx, event)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *queuedTransportEventBus) deliverAll(ctx context.Context) error {
|
|
for _, subscription := range b.subscriptions {
|
|
for len(subscription.events) > 0 {
|
|
event := subscription.events[0]
|
|
subscription.events = subscription.events[1:]
|
|
if err := subscription.handler(ctx, event); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *subscriptionRecordingEventBus) Subscribe(subject string, _ bus.EventHandler) (bus.Subscription, error) {
|
|
b.subjects = append(b.subjects, subject)
|
|
return recordingSubscription{}, nil
|
|
}
|
|
|
|
func TestTaskEventBroadcaster_UsesOrderedWildcardsForRelatedTaskEvents(t *testing.T) {
|
|
log := testLogger()
|
|
eventBus := &subscriptionRecordingEventBus{MemoryEventBus: bus.NewMemoryEventBus(log)}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
hub := NewHub(nil, log)
|
|
b := RegisterTaskNotifications(ctx, eventBus, hub, log)
|
|
|
|
wildcardCount := 0
|
|
orderedSubjects := map[string]bool{
|
|
events.TaskStateChanged: true,
|
|
events.TaskSessionStateChanged: true,
|
|
events.MessageAdded: true,
|
|
events.MessageUpdated: true,
|
|
events.MessageDeleted: true,
|
|
}
|
|
for _, subject := range eventBus.subjects {
|
|
if subject == ">" {
|
|
wildcardCount++
|
|
}
|
|
if orderedSubjects[subject] {
|
|
t.Fatalf("ordered task event subject %q must use the shared wildcard subscription", subject)
|
|
}
|
|
}
|
|
require.Equal(t, 2, wildcardCount, "each ordered event group must use one NATS-style subscription")
|
|
|
|
_ = b
|
|
}
|
|
|
|
func TestTaskEventBroadcaster_OrdersLifecycleStateNotifications(t *testing.T) {
|
|
log := testLogger()
|
|
eventBus := bus.NewMemoryEventBus(log)
|
|
hub := NewHub(nil, log)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
_ = RegisterTaskNotifications(ctx, eventBus, hub, log)
|
|
|
|
workspaceID := "workspace-1"
|
|
_ = eventBus.Publish(ctx, events.TaskStateChanged, bus.NewEvent(
|
|
events.TaskStateChanged,
|
|
"test",
|
|
map[string]interface{}{"task_id": "task-1", "workspace_id": workspaceID, "state": "IN_PROGRESS"},
|
|
))
|
|
_ = eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(
|
|
events.TaskSessionStateChanged,
|
|
"test",
|
|
map[string]interface{}{"task_id": "task-1", "session_id": "session-1", "workspace_id": workspaceID, "new_state": "RUNNING"},
|
|
))
|
|
|
|
first := <-hub.broadcast
|
|
second := <-hub.broadcast
|
|
if first.Action != ws.ActionTaskStateChanged || second.Action != ws.ActionSessionStateChanged {
|
|
t.Fatalf("lifecycle notification order = %q, %q; want task state then session state", first.Action, second.Action)
|
|
}
|
|
}
|
|
|
|
func TestTaskEventBroadcaster_OrdersTranscriptMutationsAcrossTransportSubjects(t *testing.T) {
|
|
log := testLogger()
|
|
eventBus := &queuedTransportEventBus{MemoryEventBus: bus.NewMemoryEventBus(log)}
|
|
hub := newTestHub(t)
|
|
client := newTestClient("ordered-transcript")
|
|
registerTestClient(hub, client)
|
|
hub.SubscribeToSession(client, "session-1")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
_ = RegisterTaskNotifications(ctx, eventBus, hub, log)
|
|
|
|
deleted := bus.NewEvent(events.MessageDeleted, "test", map[string]interface{}{
|
|
"session_id": "session-1", "message_id": "abandoned-message",
|
|
})
|
|
added := bus.NewEvent(events.MessageAdded, "test", map[string]interface{}{
|
|
"session_id": "session-1", "message_id": "replacement-message",
|
|
})
|
|
require.NoError(t, eventBus.Publish(ctx, events.MessageDeleted, deleted))
|
|
require.NoError(t, eventBus.Publish(ctx, events.MessageAdded, added))
|
|
|
|
// A NATS callback on an independent message.added subscription can run
|
|
// while message.deleted is delayed. A shared subscription must keep the
|
|
// replacement queued behind the deletion even under that schedule.
|
|
require.NoError(t, eventBus.deliverSubjectFirst(ctx, events.MessageAdded))
|
|
require.NoError(t, eventBus.deliverAll(ctx))
|
|
|
|
actions := make([]string, 0, 2)
|
|
for range 2 {
|
|
select {
|
|
case data := <-client.send:
|
|
var message ws.Message
|
|
require.NoError(t, json.Unmarshal(data, &message))
|
|
actions = append(actions, message.Action)
|
|
default:
|
|
t.Fatal("expected two transcript notifications")
|
|
}
|
|
}
|
|
require.Equal(t, []string{
|
|
ws.ActionSessionMessageDeleted,
|
|
ws.ActionSessionMessageAdded,
|
|
}, actions)
|
|
}
|
|
|
|
// TestTaskEventBroadcaster_NoDuplicateSubscriptions verifies that
|
|
// RegisterTaskNotifications creates one subscription per routed subject (with
|
|
// lifecycle state events intentionally sharing one ordered wildcard).
|
|
//
|
|
// The old code had a second subscription system (subscribeEventBusHandlers in
|
|
// cmd/kandev/helpers.go) that subscribed to the same routed events, causing
|
|
// duplicate broadcasts. This test counts the broadcaster's internal
|
|
// subscriptions directly to guard against re-introducing duplicates.
|
|
func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) {
|
|
log := testLogger()
|
|
eventBus := bus.NewMemoryEventBus(log)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
hub := NewHub(nil, log)
|
|
go hub.Run(ctx)
|
|
|
|
b := RegisterTaskNotifications(ctx, eventBus, hub, log)
|
|
|
|
// Count how many subscriptions RegisterTaskNotifications creates. If any
|
|
// event is subscribed twice, this count will increase and the test will fail.
|
|
//
|
|
// Update this number when adding or removing event subscriptions in
|
|
// RegisterTaskNotifications — it is intentionally exact.
|
|
const wantSubscriptions = 77
|
|
if got := len(b.subscriptions); got != wantSubscriptions {
|
|
t.Errorf("RegisterTaskNotifications created %d subscriptions, want %d — "+
|
|
"did an event get subscribed twice?", got, wantSubscriptions)
|
|
}
|
|
|
|
// Verify no event subject appears more than once by publishing each of
|
|
// the 4 previously-duplicated events and counting hub broadcasts.
|
|
// We use a fresh event bus per sub-test so each has exactly
|
|
// 1 broadcaster subscription + 1 counter subscription.
|
|
for _, subject := range []string{
|
|
events.MessageAdded,
|
|
events.MessageUpdated,
|
|
events.TaskSessionStateChanged,
|
|
events.TaskSessionActivityChanged,
|
|
events.GitHubTaskPRUpdated,
|
|
events.GitHubTaskPRDeleted,
|
|
events.GitLabTaskMRUpdated,
|
|
events.GitLabTaskMRDeleted,
|
|
events.TaskPreviewFeedbackChanged,
|
|
} {
|
|
subject := subject
|
|
t.Run(subject, func(t *testing.T) {
|
|
perEventBus := bus.NewMemoryEventBus(log)
|
|
perHub := NewHub(nil, log)
|
|
go perHub.Run(ctx)
|
|
|
|
_ = RegisterTaskNotifications(ctx, perEventBus, perHub, log)
|
|
|
|
var count int
|
|
_, _ = perEventBus.Subscribe(subject, func(_ context.Context, _ *bus.Event) error {
|
|
count++
|
|
return nil
|
|
})
|
|
|
|
data := map[string]interface{}{
|
|
"session_id": "s1", "task_id": "t1",
|
|
}
|
|
evt := bus.NewEvent(subject, "test", data)
|
|
_ = perEventBus.Publish(context.Background(), subject, evt)
|
|
|
|
// This verifies that publishing one event reaches our handler exactly
|
|
// once. Duplicate-subscription detection is handled by the
|
|
// len(b.subscriptions) guard above; these sub-tests cover event
|
|
// delivery correctness for the four previously-duplicated subjects.
|
|
if count != 1 {
|
|
t.Errorf("counter handler fired %d times, want 1", count)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTaskEventBroadcaster_PreservesAllFields verifies that event data passes through the
|
|
// event bus unmodified — the broadcaster receives the same object that was published,
|
|
// including fields like turn_id, raw_content, and updated_at that the old
|
|
// subscribeEventBusHandlers used to strip before constructing a new payload manually.
|
|
func TestTaskEventBroadcaster_PreservesAllFields(t *testing.T) {
|
|
log := testLogger()
|
|
eventBus := bus.NewMemoryEventBus(log)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
hub := NewHub(nil, log)
|
|
go hub.Run(ctx)
|
|
|
|
// Subscribe a handler before the broadcaster to capture what data
|
|
// the event bus delivers — the broadcaster receives the same object.
|
|
var captured interface{}
|
|
_, _ = eventBus.Subscribe(events.MessageAdded, func(_ context.Context, ev *bus.Event) error {
|
|
captured = ev.Data
|
|
return nil
|
|
})
|
|
|
|
_ = RegisterTaskNotifications(ctx, eventBus, hub, log)
|
|
|
|
original := map[string]interface{}{
|
|
"session_id": "s1",
|
|
"task_id": "t1",
|
|
"message_id": "m1",
|
|
"content": "hello world",
|
|
"raw_content": "raw hello world",
|
|
"turn_id": "turn-abc",
|
|
"author_type": "agent",
|
|
"author_id": "claude",
|
|
"type": "text",
|
|
"created_at": "2026-04-20T00:00:00Z",
|
|
"updated_at": "2026-04-20T00:01:00Z",
|
|
"metadata": map[string]interface{}{"key": "value"},
|
|
}
|
|
|
|
evt := bus.NewEvent(events.MessageAdded, "test", original)
|
|
_ = eventBus.Publish(context.Background(), events.MessageAdded, evt)
|
|
|
|
capturedMap, ok := captured.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("captured data is not map[string]interface{}, got %T", captured)
|
|
}
|
|
|
|
// Verify fields that the old handler used to strip are still present.
|
|
for _, field := range []string{"turn_id", "raw_content", "updated_at"} {
|
|
if _, exists := capturedMap[field]; !exists {
|
|
t.Errorf("field %q was stripped from event data", field)
|
|
}
|
|
}
|
|
|
|
origJSON, _ := json.Marshal(original)
|
|
capturedJSON, _ := json.Marshal(capturedMap)
|
|
if string(origJSON) != string(capturedJSON) {
|
|
t.Errorf("event data was modified\noriginal: %s\ncaptured: %s", origJSON, capturedJSON)
|
|
}
|
|
}
|
|
|
|
func TestTaskEventBroadcaster_ProjectsConversationReceiptFromEventData(t *testing.T) {
|
|
log := testLogger()
|
|
hub := NewHub(nil, log)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go hub.Run(ctx)
|
|
client := newTestClient("conversation-client")
|
|
client.hub = hub
|
|
client.conversationSubscriptions = map[string]conversationSubscription{
|
|
"core-scope": {
|
|
ScopeID: "core-scope", SessionID: "session-1", ConsumerKind: conversationConsumerCore,
|
|
Epoch: "epoch-1",
|
|
},
|
|
}
|
|
registerTestClient(hub, client)
|
|
broadcaster := &TaskEventBroadcaster{hub: hub, logger: log}
|
|
|
|
receipt := &models.ConversationMutationReceipt{
|
|
SessionID: "session-1", BaseRevision: 4, Revision: 5, Complete: true,
|
|
Operations: []models.ConversationMutationOperation{{
|
|
Kind: models.ConversationMutationUpsert, Entity: models.ConversationEntityMessage,
|
|
ID: "message-1", SessionID: "session-1", AuthorType: string(models.MessageAuthorUser),
|
|
Message: &models.Message{ID: "message-1", TaskSessionID: "session-1", AuthorType: models.MessageAuthorUser, Content: "prompt"},
|
|
}},
|
|
}
|
|
|
|
require.NoError(t, broadcaster.broadcastEvent(context.Background(), bus.NewEvent(
|
|
events.MessageAdded,
|
|
"task-service",
|
|
map[string]interface{}{
|
|
"session_id": "session-1",
|
|
"conversation_receipt": receipt,
|
|
},
|
|
), ws.ActionSessionMessageAdded))
|
|
|
|
frame := <-client.send
|
|
var message ws.Message
|
|
require.NoError(t, json.Unmarshal(frame, &message))
|
|
if message.Action != ws.ActionSessionConversationChanged {
|
|
t.Fatalf("action = %q, want %q", message.Action, ws.ActionSessionConversationChanged)
|
|
}
|
|
}
|
|
|
|
func TestTaskEventBroadcasterDoesNotResetConversationOnReceiptlessSessionRemoval(t *testing.T) {
|
|
hub := newTestHub(t)
|
|
client := newTestClient("conversation-client")
|
|
client.hub = hub
|
|
registerTestClient(hub, client)
|
|
client.conversationSubscriptions = map[string]conversationSubscription{
|
|
"core-scope": {
|
|
ScopeID: "core-scope", SessionID: "session-1", ConsumerKind: conversationConsumerCore,
|
|
Epoch: "epoch-1",
|
|
},
|
|
}
|
|
hub.SubscribeToSession(client, "session-1")
|
|
broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()}
|
|
msg, err := ws.NewNotification(ws.ActionSessionRemoved, map[string]any{"session_id": "session-1"})
|
|
require.NoError(t, err)
|
|
require.NoError(t, broadcaster.routeBroadcast(ws.ActionSessionRemoved, msg.Payload, "session-1", "", msg))
|
|
|
|
frame := <-client.send
|
|
var received ws.Message
|
|
require.NoError(t, json.Unmarshal(frame, &received))
|
|
if received.Action != ws.ActionSessionRemoved {
|
|
t.Fatalf("first action = %q, want session removal", received.Action)
|
|
}
|
|
select {
|
|
case extra := <-client.send:
|
|
t.Fatalf("receiptless removal emitted an extra conversation frame: %s", extra)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestTaskEventBroadcaster_CancellationIsSessionScoped(t *testing.T) {
|
|
h := newTestHub(t)
|
|
first := newTestClient("first")
|
|
second := newTestClient("second")
|
|
registerTestClient(h, first)
|
|
registerTestClient(h, second)
|
|
h.SubscribeToSession(first, "session-1")
|
|
h.SubscribeToSession(second, "session-2")
|
|
|
|
msg, err := ws.NewNotification("session.cancellation_changed", map[string]any{
|
|
"session_id": "session-1",
|
|
"cancellation_pending": true,
|
|
})
|
|
require.NoError(t, err)
|
|
b := &TaskEventBroadcaster{hub: h, logger: testLogger()}
|
|
require.NoError(t, b.routeBroadcast("session.cancellation_changed", msg.Payload, "session-1", "", msg))
|
|
|
|
if !clientReceived(first) {
|
|
t.Fatal("session subscriber did not receive cancellation notification")
|
|
}
|
|
if clientReceived(second) {
|
|
t.Fatal("cancellation notification crossed the session boundary")
|
|
}
|
|
}
|
|
|
|
func TestTaskEventBroadcaster_PlanCommentsAreTaskScoped(t *testing.T) {
|
|
hub := newTestHub(t)
|
|
first := newTestClient("first")
|
|
second := newTestClient("second")
|
|
registerTestClient(hub, first)
|
|
registerTestClient(hub, second)
|
|
hub.SubscribeToTask(first, "task-1")
|
|
hub.SubscribeToTask(second, "task-2")
|
|
broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()}
|
|
snapshot := &models.TaskPlanCommentSnapshot{TaskID: "task-1", PlanID: "plan-1"}
|
|
|
|
require.NoError(t, broadcaster.broadcastEvent(context.Background(), bus.NewEvent(
|
|
events.TaskPlanCommentsChanged,
|
|
"test",
|
|
snapshot,
|
|
), ws.ActionTaskPlanCommentsChanged))
|
|
|
|
if !clientReceived(first) {
|
|
t.Fatal("task subscriber did not receive plan comments notification")
|
|
}
|
|
if clientReceived(second) {
|
|
t.Fatal("plan comments notification crossed the task boundary")
|
|
}
|
|
}
|
|
|
|
func TestTaskEventBroadcaster_DropsTaskScopedQueueStatusWithoutSession(t *testing.T) {
|
|
hub := newTestHub(t)
|
|
hub.setAuthPolicy(AuthPolicy{Enforced: func() bool { return true }})
|
|
broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()}
|
|
payload := map[string]any{
|
|
"task_id": "task-without-session",
|
|
"queue_status_scope": "task",
|
|
}
|
|
|
|
require.NoError(t, broadcaster.broadcastEvent(
|
|
context.Background(),
|
|
bus.NewEvent(events.MessageQueueStatusChanged, "test", payload),
|
|
ws.ActionMessageQueueStatusChanged,
|
|
))
|
|
|
|
select {
|
|
case leaked := <-hub.broadcast:
|
|
t.Fatalf("task-scoped queue status was globally broadcast: %s", leaked.Action)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestTaskEventBroadcaster_DropsUnscopedGitHubCIOptionsWhenAuthIsEnforced(t *testing.T) {
|
|
hub := newTestHub(t)
|
|
hub.setAuthPolicy(AuthPolicy{Enforced: func() bool { return true }})
|
|
msg, err := ws.NewNotification(ws.ActionGitHubTaskCIOptionsUpdated, map[string]any{
|
|
"task_id": "task-without-workspace",
|
|
})
|
|
require.NoError(t, err)
|
|
broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()}
|
|
|
|
require.NoError(t, broadcaster.routeBroadcast(
|
|
ws.ActionGitHubTaskCIOptionsUpdated,
|
|
msg.Payload,
|
|
"",
|
|
"",
|
|
msg,
|
|
))
|
|
|
|
select {
|
|
case leaked := <-hub.broadcast:
|
|
t.Fatalf("unscoped GitHub CI options update was globally broadcast: %s", leaked.Action)
|
|
default:
|
|
}
|
|
}
|
|
func TestTaskEventBroadcaster_DropsUnscopedQueueStatus(t *testing.T) {
|
|
hub := newTestHub(t)
|
|
hub.setAuthPolicy(AuthPolicy{Enforced: func() bool { return true }})
|
|
msg, err := ws.NewNotification(ws.ActionMessageQueueStatusChanged, map[string]any{
|
|
"task_id": "task-without-session",
|
|
})
|
|
require.NoError(t, err)
|
|
broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()}
|
|
|
|
require.NoError(t, broadcaster.routeBroadcast(
|
|
ws.ActionMessageQueueStatusChanged,
|
|
msg.Payload,
|
|
"",
|
|
"",
|
|
msg,
|
|
))
|
|
|
|
select {
|
|
case leaked := <-hub.broadcast:
|
|
t.Fatalf("unscoped queue status was globally broadcast: %s", leaked.Action)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestTaskEventBroadcaster_ScopesTypedGitHubTaskPRUpdateToOwningWorkspace(t *testing.T) {
|
|
hub := newTestHub(t)
|
|
hub.setAuthPolicy(AuthPolicy{
|
|
Enforced: func() bool { return true },
|
|
WorkspaceOwner: func(_ context.Context, workspaceID string) (string, error) {
|
|
if workspaceID == "workspace-a" {
|
|
return "user-a", nil
|
|
}
|
|
return "", errors.New("unknown workspace")
|
|
},
|
|
})
|
|
clientA := newTestClient("client-a")
|
|
clientA.identity = authn.Identity{UserID: "user-a", Role: authn.RoleMember}
|
|
clientB := newTestClient("client-b")
|
|
clientB.identity = authn.Identity{UserID: "user-b", Role: authn.RoleMember}
|
|
registerTestClient(hub, clientA)
|
|
registerTestClient(hub, clientB)
|
|
|
|
payload := &githubsvc.TaskPR{
|
|
ID: "association-a",
|
|
WorkspaceID: "workspace-a",
|
|
TaskID: "task-a",
|
|
}
|
|
broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()}
|
|
msg := bus.NewEvent(events.GitHubTaskPRUpdated, "test", payload)
|
|
|
|
require.NoError(t, broadcaster.broadcastEvent(context.Background(), msg, ws.ActionGitHubTaskPRUpdated))
|
|
if !clientReceived(clientA) {
|
|
t.Fatal("owner of workspace-a did not receive task PR update")
|
|
}
|
|
if clientReceived(clientB) {
|
|
t.Fatal("task PR update crossed the workspace boundary")
|
|
}
|
|
select {
|
|
case leaked := <-hub.broadcast:
|
|
t.Fatalf("task PR update used the global broadcast path: %s", leaked.Action)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestTaskEventBroadcaster_DropsUnscopedGitHubTaskPRUpdateWhenAuthIsEnforced(t *testing.T) {
|
|
hub := newTestHub(t)
|
|
hub.setAuthPolicy(AuthPolicy{Enforced: func() bool { return true }})
|
|
broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()}
|
|
payload := &githubsvc.TaskPR{TaskID: "task-without-workspace"}
|
|
|
|
require.NoError(t, broadcaster.broadcastEvent(
|
|
context.Background(),
|
|
bus.NewEvent(events.GitHubTaskPRUpdated, "test", payload),
|
|
ws.ActionGitHubTaskPRUpdated,
|
|
))
|
|
select {
|
|
case leaked := <-hub.broadcast:
|
|
t.Fatalf("unscoped task PR update was globally broadcast: %s", leaked.Action)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestTaskEventBroadcaster_ScopesGitHubCIOptionsToOwningWorkspace(t *testing.T) {
|
|
hub := newTestHub(t)
|
|
hub.setAuthPolicy(AuthPolicy{
|
|
Enforced: func() bool { return true },
|
|
WorkspaceOwner: func(_ context.Context, workspaceID string) (string, error) {
|
|
switch workspaceID {
|
|
case "workspace-a":
|
|
return "user-a", nil
|
|
case "workspace-b":
|
|
return "user-b", nil
|
|
default:
|
|
return "", errors.New("unknown workspace")
|
|
}
|
|
},
|
|
})
|
|
clientA := newTestClient("client-a")
|
|
clientA.identity = authn.Identity{UserID: "user-a", Role: authn.RoleMember}
|
|
clientB := newTestClient("client-b")
|
|
clientB.identity = authn.Identity{UserID: "user-b", Role: authn.RoleMember}
|
|
registerTestClient(hub, clientA)
|
|
registerTestClient(hub, clientB)
|
|
broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()}
|
|
|
|
for _, test := range []struct {
|
|
workspaceID string
|
|
owner *Client
|
|
foreign *Client
|
|
}{
|
|
{workspaceID: "workspace-a", owner: clientA, foreign: clientB},
|
|
{workspaceID: "workspace-b", owner: clientB, foreign: clientA},
|
|
} {
|
|
payload := &githubsvc.TaskCIOptionsResponse{TaskID: "task-" + test.workspaceID, WorkspaceID: test.workspaceID}
|
|
msg, err := ws.NewNotification(ws.ActionGitHubTaskCIOptionsUpdated, payload)
|
|
require.NoError(t, err)
|
|
require.NoError(t, broadcaster.routeBroadcast(
|
|
ws.ActionGitHubTaskCIOptionsUpdated,
|
|
payload,
|
|
"",
|
|
extractWorkspaceID(payload),
|
|
msg,
|
|
))
|
|
if !clientReceived(test.owner) {
|
|
t.Fatalf("owner of %s did not receive CI options update", test.workspaceID)
|
|
}
|
|
if clientReceived(test.foreign) {
|
|
t.Fatalf("CI options update for %s crossed workspace boundary", test.workspaceID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTaskEventBroadcaster_CancellationSubscriptionIsSessionScoped(t *testing.T) {
|
|
log := testLogger()
|
|
eventBus := bus.NewMemoryEventBus(log)
|
|
hub := newTestHub(t)
|
|
first := newTestClient("first")
|
|
second := newTestClient("second")
|
|
registerTestClient(hub, first)
|
|
registerTestClient(hub, second)
|
|
hub.SubscribeToSession(first, "session-1")
|
|
hub.SubscribeToSession(second, "session-2")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
b := RegisterTaskNotifications(ctx, eventBus, hub, log)
|
|
t.Cleanup(b.Close)
|
|
|
|
require.NoError(t, eventBus.Publish(ctx, events.TaskSessionCancellationChanged, bus.NewEvent(
|
|
events.TaskSessionCancellationChanged,
|
|
"test",
|
|
map[string]any{"session_id": "session-1", "cancellation_pending": true},
|
|
)))
|
|
|
|
var notification ws.Message
|
|
received := <-first.send
|
|
require.NoError(t, json.Unmarshal(received, ¬ification))
|
|
require.Equal(t, ws.ActionSessionCancellationChanged, notification.Action)
|
|
if clientReceived(second) {
|
|
t.Fatal("cancellation subscription crossed the session boundary")
|
|
}
|
|
}
|
|
|
|
func TestTaskEventBroadcaster_DropsUnscopedSessionPendingActionWhenAuthIsEnforced(t *testing.T) {
|
|
hub := newTestHub(t)
|
|
hub.setAuthPolicy(AuthPolicy{Enforced: func() bool { return true }})
|
|
client := newTestClient("client")
|
|
client.identity = authn.Identity{UserID: "user-a", Role: authn.RoleMember}
|
|
registerTestClient(hub, client)
|
|
|
|
msg, err := ws.NewNotification("session.pending_action_changed", map[string]any{
|
|
"task_id": "task-without-workspace",
|
|
"session_id": "session-1",
|
|
"pending_action": "clarification",
|
|
"pending_action_revision": map[string]any{
|
|
"epoch": "epoch-1",
|
|
"sequence": 1,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()}
|
|
|
|
require.NoError(t, broadcaster.routeBroadcast(
|
|
"session.pending_action_changed",
|
|
msg.Payload,
|
|
"session-1",
|
|
"",
|
|
msg,
|
|
))
|
|
if clientReceived(client) {
|
|
t.Fatal("unscoped session pending-action event was globally broadcast")
|
|
}
|
|
}
|
|
|
|
func TestTaskEventBroadcaster_ScopesSessionPendingActionToOwningWorkspace(t *testing.T) {
|
|
hub := newTestHub(t)
|
|
hub.setAuthPolicy(AuthPolicy{
|
|
Enforced: func() bool { return true },
|
|
WorkspaceOwner: func(_ context.Context, workspaceID string) (string, error) {
|
|
if workspaceID == "workspace-a" {
|
|
return "user-a", nil
|
|
}
|
|
return "", errors.New("unknown workspace")
|
|
},
|
|
})
|
|
owner := newTestClient("owner")
|
|
owner.identity = authn.Identity{UserID: "user-a", Role: authn.RoleMember}
|
|
foreign := newTestClient("foreign")
|
|
foreign.identity = authn.Identity{UserID: "user-b", Role: authn.RoleMember}
|
|
registerTestClient(hub, owner)
|
|
registerTestClient(hub, foreign)
|
|
|
|
msg, err := ws.NewNotification(ws.ActionSessionPendingActionChanged, map[string]any{
|
|
"workspace_id": "workspace-a",
|
|
"task_id": "task-a",
|
|
"session_id": "session-a",
|
|
"pending_action": nil,
|
|
"pending_action_revision": map[string]any{"epoch": "1", "sequence": 2},
|
|
})
|
|
require.NoError(t, err)
|
|
broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()}
|
|
require.NoError(t, broadcaster.routeBroadcast(
|
|
ws.ActionSessionPendingActionChanged,
|
|
msg.Payload,
|
|
"session-a",
|
|
"workspace-a",
|
|
msg,
|
|
))
|
|
if !clientReceived(owner) {
|
|
t.Fatal("workspace owner did not receive session pending-action event")
|
|
}
|
|
if clientReceived(foreign) {
|
|
t.Fatal("session pending-action event crossed the workspace boundary")
|
|
}
|
|
}
|