2026-08-20 19:29:31 +02:00
|
|
|
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"
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
"github.com/J3vb/OwnCord/Server/auth"
|
|
|
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
|
|
|
"github.com/J3vb/OwnCord/Server/plugin"
|
|
|
|
|
"github.com/J3vb/OwnCord/Server/service"
|
2026-08-20 19:29:31 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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))
|
|
|
|
|
}
|
|
|
|
|
}
|