Files
kandev/apps/backend/internal/gateway/websocket/user_notifications_test.go
T
Clément Fiere 15c0c35df6 feat(auth): resolve session hostnames (#2741)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-09-04 13:06:33 +00:00

270 lines
9.7 KiB
Go

package websocket
import (
"context"
"encoding/json"
"testing"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
ws "github.com/kandev/kandev/pkg/websocket"
)
// pluginUserStateTestEvent mirrors internal/plugins' pluginUserStateUpdatedEvent
// (a plain struct with an exported UserID, json:"user_id") without a
// plugins package import (avoiding a gateway/websocket -> plugins dependency
// this package doesn't otherwise have).
type pluginUserStateTestEvent struct {
UserID string `json:"user_id"`
PluginID string `json:"pluginId"`
Key string `json:"key"`
}
func testLoggerForUserNotifications(t *testing.T) *logger.Logger {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
if err != nil {
t.Fatalf("logger: %v", err)
}
return log
}
// TestRegisterUserNotifications_PluginUserStateReachesOnlyWriter pins AC24:
// a plugin.user-state.updated bus event reaches only the writing user's WS
// connections; a second, differently-identified user's client receives
// nothing.
func TestRegisterUserNotifications_PluginUserStateReachesOnlyWriter(t *testing.T) {
h := newTestHub(t)
writer := newTestClient("writer-conn")
other := newTestClient("other-conn")
registerTestClient(h, writer)
registerTestClient(h, other)
h.SubscribeToUser(writer, "user_1")
h.SubscribeToUser(other, "user_2")
eventBus := bus.NewMemoryEventBus(testLoggerForUserNotifications(t))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
RegisterUserNotifications(ctx, eventBus, h, testLoggerForUserNotifications(t))
payload := pluginUserStateTestEvent{UserID: "user_1", PluginID: "kandev-plugin-notes", Key: "note"}
if err := eventBus.Publish(ctx, events.PluginUserStateUpdated,
bus.NewEvent(events.PluginUserStateUpdated, "test", payload)); err != nil {
t.Fatalf("publish: %v", err)
}
if !clientReceived(writer) {
t.Fatal("expected the writing user's client to receive the notification")
}
if clientReceived(other) {
t.Fatal("expected a different user's client to receive nothing")
}
}
// TestRegisterUserNotifications_PluginUserStatePayloadShape pins that the WS
// notification payload carries exactly the plugin/scope/key fields (AC24) —
// no user_id leaks into the message the client receives.
func TestRegisterUserNotifications_PluginUserStatePayloadShape(t *testing.T) {
h := newTestHub(t)
writer := newTestClient("writer-conn")
registerTestClient(h, writer)
h.SubscribeToUser(writer, "user_1")
eventBus := bus.NewMemoryEventBus(testLoggerForUserNotifications(t))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
RegisterUserNotifications(ctx, eventBus, h, testLoggerForUserNotifications(t))
payload := pluginUserStateTestEvent{UserID: "user_1", PluginID: "kandev-plugin-notes", Key: "note"}
if err := eventBus.Publish(ctx, events.PluginUserStateUpdated,
bus.NewEvent(events.PluginUserStateUpdated, "test", payload)); err != nil {
t.Fatalf("publish: %v", err)
}
select {
case raw := <-writer.send:
var msg ws.Message
if err := json.Unmarshal(raw, &msg); err != nil {
t.Fatalf("unmarshal envelope: %v", err)
}
if msg.Action != "plugin.user-state.updated" {
t.Fatalf("Action = %q, want %q", msg.Action, "plugin.user-state.updated")
}
var got map[string]any
if err := json.Unmarshal(msg.Payload, &got); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if got["pluginId"] != "kandev-plugin-notes" || got["key"] != "note" {
t.Fatalf("payload = %+v, missing expected fields", got)
}
if _, leaked := got["user_id"]; leaked {
t.Fatalf("payload leaked user_id: %+v", got)
}
default:
t.Fatal("expected a message on writer.send")
}
}
// TestRegisterUserNotifications_PluginUserStateRoutesUnderNATSTransport is
// the regression test for the NATS routing gap found in review: a
// NATS-backed EventBus marshals every event before publishing and
// JSON-decodes it into an untyped Data field on arrival
// (internal/events/bus/nats.go's createMsgHandler does
// json.Unmarshal(msg.Data, &event)), so a subscriber always sees
// event.Data as a map[string]interface{} — never the original Go struct.
// This reproduces exactly that shape (marshal the real event struct, then
// unmarshal into a bus.Event the same way nats.go does) without needing a
// live NATS server, and proves routing plus the no-leak-to-the-browser
// contract both still hold once event.Data arrives as a map instead of a
// struct.
func TestRegisterUserNotifications_PluginUserStateRoutesUnderNATSTransport(t *testing.T) {
h := newTestHub(t)
writer := newTestClient("writer-conn")
registerTestClient(h, writer)
h.SubscribeToUser(writer, "user_1")
eventBus := bus.NewMemoryEventBus(testLoggerForUserNotifications(t))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
RegisterUserNotifications(ctx, eventBus, h, testLoggerForUserNotifications(t))
published := bus.NewEvent(events.PluginUserStateUpdated, "test",
pluginUserStateTestEvent{UserID: "user_1", PluginID: "kandev-plugin-notes", Key: "note"})
wire, err := json.Marshal(published)
if err != nil {
t.Fatalf("marshal (simulating what a NATS publisher sends over the wire): %v", err)
}
var arrived bus.Event
if err := json.Unmarshal(wire, &arrived); err != nil {
t.Fatalf("unmarshal (simulating nats.go's createMsgHandler on receipt): %v", err)
}
if _, ok := arrived.Data.(map[string]interface{}); !ok {
t.Fatalf("arrived.Data = %T, want map[string]interface{} (the whole point of this test)", arrived.Data)
}
if err := eventBus.Publish(ctx, events.PluginUserStateUpdated, &arrived); err != nil {
t.Fatalf("publish: %v", err)
}
select {
case raw := <-writer.send:
var msg ws.Message
if err := json.Unmarshal(raw, &msg); err != nil {
t.Fatalf("unmarshal envelope: %v", err)
}
var got map[string]any
if err := json.Unmarshal(msg.Payload, &got); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if got["pluginId"] != "kandev-plugin-notes" || got["key"] != "note" {
t.Fatalf("payload = %+v, missing expected fields", got)
}
if _, leaked := got["user_id"]; leaked {
t.Fatalf("payload leaked user_id: %+v", got)
}
default:
t.Fatal("expected the writing user's client to receive the notification even when event.Data arrives as a map (NATS transport)")
}
}
func TestRegisterUserNotifications_AgentProfileRecentUseReachesOnlyOwner(t *testing.T) {
h := newTestHub(t)
owner := newTestClient("owner-conn")
other := newTestClient("other-conn")
registerTestClient(h, owner)
registerTestClient(h, other)
h.SubscribeToUser(owner, "user_1")
h.SubscribeToUser(other, "user_2")
eventBus := bus.NewMemoryEventBus(testLoggerForUserNotifications(t))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
RegisterUserNotifications(ctx, eventBus, h, testLoggerForUserNotifications(t))
if err := eventBus.Publish(ctx, events.UserAgentProfileRecentUseUpdated, bus.NewEvent(
events.UserAgentProfileRecentUseUpdated,
"test",
map[string]any{
"user_id": "user_1",
"context": "quick_chat",
"profile_ids": []string{"profile-a"},
"revision": int64(1),
"updated_at": "2026-08-27T00:00:00Z",
},
)); err != nil {
t.Fatalf("publish: %v", err)
}
select {
case raw := <-owner.send:
var message ws.Message
if err := json.Unmarshal(raw, &message); err != nil {
t.Fatalf("unmarshal envelope: %v", err)
}
if message.Action != ws.ActionUserAgentProfileRecentUseUpdated {
t.Fatalf("action = %q, want %q", message.Action, ws.ActionUserAgentProfileRecentUseUpdated)
}
var payload map[string]any
if err := json.Unmarshal(message.Payload, &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if _, leaked := payload["user_id"]; leaked {
t.Fatalf("payload leaked user_id: %+v", payload)
}
default:
t.Fatal("expected owner notification")
}
if clientReceived(other) {
t.Fatal("expected other user's client to receive nothing")
}
}
func TestRegisterUserNotifications_SessionHostnameResolvedRoutesAndStripsUser(t *testing.T) {
hub := newTestHub(t)
owner := newTestClient("owner")
other := newTestClient("other")
registerTestClient(hub, owner)
registerTestClient(hub, other)
hub.SubscribeToUser(owner, "user_1")
hub.SubscribeToUser(other, "user_2")
eventBus := bus.NewMemoryEventBus(testLoggerForUserNotifications(t))
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
RegisterUserNotifications(ctx, eventBus, hub, testLoggerForUserNotifications(t))
if err := eventBus.Publish(ctx, events.AuthSessionHostnameResolved, bus.NewEvent(
events.AuthSessionHostnameResolved, "test", map[string]any{
"user_id": "user_1", "ip": "192.0.2.10", "hostname": "mail.example.test",
"resolved_at": "2026-08-16T12:00:00.123456789Z",
},
)); err != nil {
t.Fatalf("publish: %v", err)
}
select {
case raw := <-owner.send:
var message ws.Message
if err := json.Unmarshal(raw, &message); err != nil {
t.Fatalf("unmarshal envelope: %v", err)
}
if message.Action != ws.ActionSessionHostnameResolved {
t.Fatalf("Action = %q, want %q", message.Action, ws.ActionSessionHostnameResolved)
}
var payload map[string]any
if err := json.Unmarshal(message.Payload, &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if payload["ip"] != "192.0.2.10" || payload["hostname"] != "mail.example.test" || payload["resolved_at"] != "2026-08-16T12:00:00.123456789Z" {
t.Fatalf("payload = %#v, want hostname resolution fields", payload)
}
if _, leaked := payload["user_id"]; leaked {
t.Fatalf("payload leaked user_id: %#v", payload)
}
default:
t.Fatal("owner did not receive hostname resolution")
}
if clientReceived(other) {
t.Fatal("non-owner received hostname resolution")
}
}