Files
OwnCord/Server/ws/handlers_command_gate_test.go
T

193 lines
7.1 KiB
Go
Raw Normal View History

package ws
// handlers_command_gate_test.go — success path of the chat_command handler:
// the ephemeral reply, the MessageService.CanPost broadcast gate, and the
// plugin_broadcast fan-out. handlers_command_test.go (package ws_test) covers
// only the refusals, which it can reach through the hub; these need PluginDeps
// and the unexported command struct, so they live in-package.
import (
"context"
"encoding/json"
"testing"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/plugin"
"github.com/J3vb/OwnCord/Server/service"
)
// stubDispatcher stands in for *plugin.Registry. A real registry is useless
// here: without the wazero build tag it has no runtime, so DispatchCommand can
// only ever return the "runtime is not built" Reply — never a Broadcast, and
// therefore never the CanPost gate below.
type stubDispatcher struct {
result *plugin.CommandResult
handled bool
}
func (s stubDispatcher) DispatchCommand(_ context.Context, _, _ int64, _ string, _ []string) (*plugin.CommandResult, bool) {
return s.result, s.handled
}
// newCommandTestDeps builds PluginDeps whose MessageSvc is the real service
// (the same CanPost a message send runs) over an in-memory DB, plus a
// dispatcher stub returning res. Returns the owner (all permissions), a user
// whose role carries none, and a text channel.
func newCommandTestDeps(t *testing.T, res *plugin.CommandResult) (deps PluginDeps, ownerID, mutedID, chID int64) {
t.Helper()
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("Migrate: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
ctx := context.Background()
if ownerID, err = database.CreateUser(ctx, "cmd-owner", "hash", 1); err != nil { // Owner role
t.Fatalf("CreateUser owner: %v", err)
}
role, err := database.CreateRole(ctx, "cmd-muted", nil, 0, 0) // no permission bits
if err != nil {
t.Fatalf("CreateRole: %v", err)
}
if mutedID, err = database.CreateUser(ctx, "cmd-muted-user", "hash", int(role.ID)); err != nil {
t.Fatalf("CreateUser muted: %v", err)
}
if chID, err = database.CreateChannel(ctx, "cmd-chan", "text", "", "", 0); err != nil {
t.Fatalf("CreateChannel: %v", err)
}
svc := service.New(database, auth.NewRateLimiter())
deps = PluginDeps{
Registry: func() CommandDispatcher { return stubDispatcher{result: res, handled: true} },
MessageSvc: svc.Messages,
}
return deps, ownerID, mutedID, chID
}
// A plugin Reply becomes an ephemeral command_reply envelope carrying the
// request's req_id, and reaches nobody else.
func TestHandleChatCommandV2_ReplyIsEphemeral(t *testing.T) {
deps, ownerID, _, chID := newCommandTestDeps(t, &plugin.CommandResult{Reply: "pong"})
cmd := ChatCommandCmd{userID: ownerID, channelID: chID, command: "/ping", reqID: "req-7"}
result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: ownerID}, deps)
if result.Error != nil {
t.Fatalf("unexpected error: %v", result.Error)
}
if len(result.Events) != 0 {
t.Fatalf("a reply-only command must not broadcast, got %d events", len(result.Events))
}
var env struct {
Type string `json:"type"`
ReqID string `json:"req_id"`
Payload struct {
Text string `json:"text"`
} `json:"payload"`
}
if err := json.Unmarshal(result.Reply, &env); err != nil {
t.Fatalf("unmarshal reply %s: %v", result.Reply, err)
}
if env.Type != MsgTypeCommandReply {
t.Errorf("type = %q, want %q", env.Type, MsgTypeCommandReply)
}
if env.ReqID != "req-7" {
t.Errorf("req_id = %q, want req-7", env.ReqID)
}
if env.Payload.Text != "pong" {
t.Errorf("text = %q, want pong", env.Payload.Text)
}
}
// An authorized user's Broadcast fans out as a plugin_broadcast event on the
// invoking channel.
func TestHandleChatCommandV2_BroadcastFansOutWhenAllowed(t *testing.T) {
deps, ownerID, _, chID := newCommandTestDeps(t, &plugin.CommandResult{Broadcast: "rolled a 6"})
cmd := ChatCommandCmd{userID: ownerID, channelID: chID, command: "/roll", reqID: "req-8"}
result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: ownerID}, deps)
if result.Error != nil {
t.Fatalf("unexpected error: %v", result.Error)
}
if len(result.Events) != 1 {
t.Fatalf("expected 1 broadcast event, got %d", len(result.Events))
}
ev, ok := result.Events[0].(PluginBroadcastEvent)
if !ok {
t.Fatalf("expected PluginBroadcastEvent, got %T", result.Events[0])
}
if ev.ChannelID() != chID {
t.Errorf("event channel = %d, want %d", ev.ChannelID(), chID)
}
var env struct {
Type string `json:"type"`
Payload struct {
ChannelID int64 `json:"channel_id"`
UserID int64 `json:"user_id"`
Command string `json:"command"`
Text string `json:"text"`
} `json:"payload"`
}
if err := json.Unmarshal(ev.Payload(), &env); err != nil {
t.Fatalf("unmarshal payload %s: %v", ev.Payload(), err)
}
if env.Type != MsgTypePluginBroadcast {
t.Errorf("type = %q, want %q", env.Type, MsgTypePluginBroadcast)
}
if env.Payload.ChannelID != chID || env.Payload.UserID != ownerID {
t.Errorf("payload ids = (%d,%d), want (%d,%d)", env.Payload.ChannelID, env.Payload.UserID, chID, ownerID)
}
if env.Payload.Command != "/roll" || env.Payload.Text != "rolled a 6" {
t.Errorf("payload = %+v, want command=/roll text=rolled a 6", env.Payload)
}
}
// The CanPost gate: a user whose role cannot post gets FORBIDDEN and nothing
// reaches the channel — even though the plugin returned a broadcast. The reply
// is dropped with it (the denial is the security signal; see handlers_command.go).
func TestHandleChatCommandV2_BroadcastDeniedWithoutPostPermission(t *testing.T) {
deps, _, mutedID, chID := newCommandTestDeps(t, &plugin.CommandResult{Reply: "ok", Broadcast: "rolled a 6"})
cmd := ChatCommandCmd{userID: mutedID, channelID: chID, command: "/roll", reqID: "req-9"}
result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: mutedID}, deps)
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T (%v)", result.Error, result.Error)
}
if ce.Code != ErrCodeForbidden {
t.Errorf("code = %q, want %q", ce.Code, ErrCodeForbidden)
}
if len(result.Events) != 0 {
t.Errorf("denied command must not broadcast, got %d events", len(result.Events))
}
if result.Reply != nil {
t.Errorf("denied command must not also reply, got %s", result.Reply)
}
}
// A broadcast aimed at a channel that does not exist is NOT_FOUND, not
// FORBIDDEN — CanPost's missing-channel branch.
func TestHandleChatCommandV2_BroadcastUnknownChannel(t *testing.T) {
deps, ownerID, _, _ := newCommandTestDeps(t, &plugin.CommandResult{Broadcast: "hi"})
cmd := ChatCommandCmd{userID: ownerID, channelID: 424242, command: "/roll"}
result := handleChatCommandV2(context.Background(), cmd, ClientInfo{UserID: ownerID}, deps)
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("expected ClientError, got %T (%v)", result.Error, result.Error)
}
if ce.Code != ErrCodeNotFound {
t.Errorf("code = %q, want %q", ce.Code, ErrCodeNotFound)
}
if len(result.Events) != 0 {
t.Errorf("expected no events, got %d", len(result.Events))
}
}