4864 lines
102 KiB
Go
4864 lines
102 KiB
Go
// Tests use a global viper instance for configuration,
|
|
// making parallel execution unsafe.
|
|
//
|
|
//nolint:paralleltest
|
|
package handlers_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
|
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
|
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
|
"git.eeqj.de/sneak/neoirc/internal/server"
|
|
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
"go.uber.org/fx"
|
|
"go.uber.org/fx/fxtest"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
db.SetBcryptCost(bcrypt.MinCost)
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
const (
|
|
commandKey = "command"
|
|
bodyKey = "body"
|
|
toKey = "to"
|
|
statusKey = "status"
|
|
privmsgCmd = "PRIVMSG"
|
|
joinCmd = "JOIN"
|
|
apiMessages = "/api/v1/messages"
|
|
apiSession = "/api/v1/session"
|
|
apiState = "/api/v1/state"
|
|
authCookieName = "neoirc_auth"
|
|
)
|
|
|
|
// testServer wraps a test HTTP server with helpers.
|
|
type testServer struct {
|
|
httpServer *httptest.Server
|
|
t *testing.T
|
|
fxApp *fxtest.App
|
|
}
|
|
|
|
func newTestServer(
|
|
t *testing.T,
|
|
) *testServer {
|
|
t.Helper()
|
|
|
|
return newTestServerWith(t, 0, "", "")
|
|
}
|
|
|
|
func newTestServerWith(
|
|
t *testing.T,
|
|
hashcashBits int,
|
|
operName, operPassword string,
|
|
) *testServer {
|
|
t.Helper()
|
|
|
|
dbURL := fmt.Sprintf(
|
|
"file:test_%p?mode=memory&cache=shared",
|
|
t,
|
|
)
|
|
|
|
var srv *server.Server
|
|
|
|
app := fxtest.New(t,
|
|
fx.Provide(
|
|
newTestGlobals,
|
|
logger.New,
|
|
func(
|
|
lifecycle fx.Lifecycle,
|
|
globs *globals.Globals,
|
|
log *logger.Logger,
|
|
) (*config.Config, error) {
|
|
cfg, err := config.New(
|
|
lifecycle, config.Params{ //nolint:exhaustruct
|
|
Globals: globs, Logger: log,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"test config: %w", err,
|
|
)
|
|
}
|
|
|
|
cfg.DBURL = dbURL
|
|
cfg.Port = 0
|
|
cfg.HashcashBits = hashcashBits
|
|
cfg.OperName = operName
|
|
cfg.OperPassword = operPassword
|
|
|
|
return cfg, nil
|
|
},
|
|
newTestDB,
|
|
stats.New,
|
|
newTestHealthcheck,
|
|
newTestMiddleware,
|
|
newTestHandlers,
|
|
newTestServerFx,
|
|
),
|
|
fx.Populate(&srv),
|
|
)
|
|
|
|
app.RequireStart()
|
|
|
|
httpSrv := httptest.NewServer(srv)
|
|
|
|
t.Cleanup(func() {
|
|
httpSrv.Close()
|
|
app.RequireStop()
|
|
})
|
|
|
|
return &testServer{
|
|
httpServer: httpSrv,
|
|
t: t,
|
|
fxApp: app,
|
|
}
|
|
}
|
|
|
|
func newTestGlobals() *globals.Globals {
|
|
return &globals.Globals{
|
|
Appname: "neoirc-test",
|
|
Version: "test",
|
|
StartTime: time.Now(),
|
|
}
|
|
}
|
|
|
|
func newTestDB(
|
|
lifecycle fx.Lifecycle,
|
|
log *logger.Logger,
|
|
cfg *config.Config,
|
|
) (*db.Database, error) {
|
|
database, err := db.New(lifecycle, db.Params{ //nolint:exhaustruct
|
|
Logger: log, Config: cfg,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("test db: %w", err)
|
|
}
|
|
|
|
return database, nil
|
|
}
|
|
|
|
func newTestHealthcheck(
|
|
lifecycle fx.Lifecycle,
|
|
globs *globals.Globals,
|
|
cfg *config.Config,
|
|
log *logger.Logger,
|
|
database *db.Database,
|
|
tracker *stats.Tracker,
|
|
) (*healthcheck.Healthcheck, error) {
|
|
hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct
|
|
Globals: globs,
|
|
Config: cfg,
|
|
Logger: log,
|
|
Database: database,
|
|
Stats: tracker,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("test healthcheck: %w", err)
|
|
}
|
|
|
|
return hcheck, nil
|
|
}
|
|
|
|
func newTestMiddleware(
|
|
lifecycle fx.Lifecycle,
|
|
log *logger.Logger,
|
|
globs *globals.Globals,
|
|
cfg *config.Config,
|
|
) (*middleware.Middleware, error) {
|
|
mware, err := middleware.New(lifecycle, middleware.Params{ //nolint:exhaustruct
|
|
Logger: log,
|
|
Globals: globs,
|
|
Config: cfg,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("test middleware: %w", err)
|
|
}
|
|
|
|
return mware, nil
|
|
}
|
|
|
|
func newTestHandlers(
|
|
lifecycle fx.Lifecycle,
|
|
log *logger.Logger,
|
|
globs *globals.Globals,
|
|
cfg *config.Config,
|
|
database *db.Database,
|
|
hcheck *healthcheck.Healthcheck,
|
|
tracker *stats.Tracker,
|
|
) (*handlers.Handlers, error) {
|
|
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
|
Logger: log,
|
|
Globals: globs,
|
|
Config: cfg,
|
|
Database: database,
|
|
Healthcheck: hcheck,
|
|
Stats: tracker,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("test handlers: %w", err)
|
|
}
|
|
|
|
return hdlr, nil
|
|
}
|
|
|
|
func newTestServerFx(
|
|
lifecycle fx.Lifecycle,
|
|
log *logger.Logger,
|
|
globs *globals.Globals,
|
|
cfg *config.Config,
|
|
mware *middleware.Middleware,
|
|
hdlr *handlers.Handlers,
|
|
) (*server.Server, error) {
|
|
srv, err := server.New(lifecycle, server.Params{ //nolint:exhaustruct
|
|
Logger: log,
|
|
Globals: globs,
|
|
Config: cfg,
|
|
Middleware: mware,
|
|
Handlers: hdlr,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("test server: %w", err)
|
|
}
|
|
|
|
return srv, nil
|
|
}
|
|
|
|
func (tserver *testServer) url(path string) string {
|
|
return tserver.httpServer.URL + path
|
|
}
|
|
|
|
func doRequest(
|
|
t *testing.T,
|
|
method, url string,
|
|
body io.Reader,
|
|
) (*http.Response, error) {
|
|
t.Helper()
|
|
|
|
request, err := http.NewRequestWithContext(
|
|
t.Context(), method, url, body,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new request: %w", err)
|
|
}
|
|
|
|
if body != nil {
|
|
request.Header.Set(
|
|
"Content-Type", "application/json",
|
|
)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func doRequestAuth(
|
|
t *testing.T,
|
|
method, url, cookie string,
|
|
body io.Reader,
|
|
) (*http.Response, error) {
|
|
t.Helper()
|
|
|
|
request, err := http.NewRequestWithContext(
|
|
t.Context(), method, url, body,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new request: %w", err)
|
|
}
|
|
|
|
if body != nil {
|
|
request.Header.Set(
|
|
"Content-Type", "application/json",
|
|
)
|
|
}
|
|
|
|
if cookie != "" {
|
|
request.AddCookie(&http.Cookie{ //nolint:exhaustruct // only name+value needed
|
|
Name: authCookieName,
|
|
Value: cookie,
|
|
})
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func (tserver *testServer) createSession(
|
|
nick string,
|
|
) string {
|
|
tserver.t.Helper()
|
|
|
|
body, err := json.Marshal(
|
|
map[string]string{"nick": nick},
|
|
)
|
|
if err != nil {
|
|
tserver.t.Fatalf("marshal session: %v", err)
|
|
}
|
|
|
|
resp, err := doRequest(
|
|
tserver.t,
|
|
http.MethodPost,
|
|
tserver.url(apiSession),
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
tserver.t.Fatalf("create session: %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
tserver.t.Fatalf(
|
|
"create session: status %d: %s",
|
|
resp.StatusCode, respBody,
|
|
)
|
|
}
|
|
|
|
// Drain the body.
|
|
_, _ = io.ReadAll(resp.Body)
|
|
|
|
// Extract auth cookie from response.
|
|
for _, cookie := range resp.Cookies() {
|
|
if cookie.Name == authCookieName {
|
|
return cookie.Value
|
|
}
|
|
}
|
|
|
|
tserver.t.Fatal("no auth cookie in response")
|
|
|
|
return ""
|
|
}
|
|
|
|
func (tserver *testServer) sendCommand(
|
|
token string, cmd map[string]any,
|
|
) (int, map[string]any) {
|
|
tserver.t.Helper()
|
|
|
|
body, err := json.Marshal(cmd)
|
|
if err != nil {
|
|
tserver.t.Fatalf("marshal command: %v", err)
|
|
}
|
|
|
|
resp, err := doRequestAuth(
|
|
tserver.t,
|
|
http.MethodPost,
|
|
tserver.url(apiMessages),
|
|
token,
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
tserver.t.Fatalf("send command: %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
var result map[string]any
|
|
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
|
|
return resp.StatusCode, result
|
|
}
|
|
|
|
func (tserver *testServer) getState(
|
|
token string,
|
|
) (int, map[string]any) {
|
|
tserver.t.Helper()
|
|
|
|
resp, err := doRequestAuth(
|
|
tserver.t,
|
|
http.MethodGet,
|
|
tserver.url(apiState),
|
|
token,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
tserver.t.Fatalf("get: %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
var result map[string]any
|
|
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
|
|
return resp.StatusCode, result
|
|
}
|
|
|
|
func (tserver *testServer) pollMessages(
|
|
token string, afterID int64,
|
|
) ([]map[string]any, int64) {
|
|
tserver.t.Helper()
|
|
|
|
pollURL := fmt.Sprintf(
|
|
"%s"+apiMessages+"?timeout=0&after=%d",
|
|
tserver.httpServer.URL, afterID,
|
|
)
|
|
|
|
resp, err := doRequestAuth(
|
|
tserver.t,
|
|
http.MethodGet,
|
|
pollURL,
|
|
token,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
tserver.t.Fatalf("poll: %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
var result struct {
|
|
Messages []map[string]any `json:"messages"`
|
|
LastID json.Number `json:"last_id"` //nolint:tagliatelle
|
|
}
|
|
|
|
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
|
if decErr != nil {
|
|
tserver.t.Fatalf("decode poll: %v", decErr)
|
|
}
|
|
|
|
lastID, _ := result.LastID.Int64()
|
|
|
|
return result.Messages, lastID
|
|
}
|
|
|
|
func postSession(
|
|
t *testing.T,
|
|
tserver *testServer,
|
|
nick string,
|
|
) *http.Response {
|
|
t.Helper()
|
|
|
|
body, err := json.Marshal(
|
|
map[string]string{"nick": nick},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodPost,
|
|
tserver.url(apiSession),
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
func findMessage(
|
|
msgs []map[string]any,
|
|
command, from string,
|
|
) bool {
|
|
for _, msg := range msgs {
|
|
if msg[commandKey] == command &&
|
|
msg["from"] == from {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func findNumeric(
|
|
msgs []map[string]any,
|
|
numeric string,
|
|
) bool {
|
|
want, _ := strconv.Atoi(numeric)
|
|
|
|
for _, msg := range msgs {
|
|
code, ok := msg["code"].(float64)
|
|
if ok && int(code) == want {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
func TestCreateSessionValid(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
cookie := tserver.createSession("alice")
|
|
|
|
if cookie == "" {
|
|
t.Fatal("expected auth cookie")
|
|
}
|
|
}
|
|
|
|
func TestWelcomeNumeric(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("welcomer")
|
|
|
|
msgs, _ := tserver.pollMessages(token, 0)
|
|
|
|
if !findNumeric(msgs, "001") {
|
|
t.Fatalf(
|
|
"expected RPL_WELCOME (001), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestJoinNumerics(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("jnumtest")
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#numtest",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "353") {
|
|
t.Fatalf(
|
|
"expected RPL_NAMREPLY (353), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
if !findNumeric(msgs, "366") {
|
|
t.Fatalf(
|
|
"expected RPL_ENDOFNAMES (366), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestCreateSessionDuplicate(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
tserver.createSession("alice")
|
|
|
|
resp := postSession(t, tserver, "alice")
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("expected 409, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestCreateSessionEmpty(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
resp := postSession(t, tserver, "")
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf(
|
|
"expected 400, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestCreateSessionInvalidChars(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
resp := postSession(t, tserver, "hello world")
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf(
|
|
"expected 400, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestCreateSessionNumericStart(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
resp := postSession(t, tserver, "123abc")
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf(
|
|
"expected 400, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestCreateSessionMalformed(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodPost,
|
|
tserver.url(apiSession),
|
|
strings.NewReader("{bad"),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf(
|
|
"expected 400, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestAuthNoCookie(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
status, _ := tserver.getState("")
|
|
if status != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestAuthBadCookie(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
status, _ := tserver.getState(
|
|
"invalid-cookie-12345",
|
|
)
|
|
if status != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestAuthValidToken(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("authtest")
|
|
|
|
status, result := tserver.getState(token)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
if result["nick"] != "authtest" {
|
|
t.Fatalf(
|
|
"expected nick authtest, got %v",
|
|
result["nick"],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestJoinChannel(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("joiner")
|
|
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: joinCmd, toKey: "#test",
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["channel"] != "#test" {
|
|
t.Fatalf(
|
|
"expected #test, got %v", result["channel"],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestJoinWithoutHash(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("joiner2")
|
|
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: joinCmd, toKey: "other",
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["channel"] != "#other" {
|
|
t.Fatalf(
|
|
"expected #other, got %v",
|
|
result["channel"],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestPartChannel(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("parter")
|
|
|
|
tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: joinCmd, toKey: "#test",
|
|
},
|
|
)
|
|
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: "PART", toKey: "#test",
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["channel"] != "#test" {
|
|
t.Fatalf(
|
|
"expected #test, got %v", result["channel"],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestJoinMissingTo(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("joiner3")
|
|
|
|
// Drain initial MOTD/welcome numerics.
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
status, _ := tserver.sendCommand(
|
|
token, map[string]any{commandKey: joinCmd},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "461") {
|
|
t.Fatalf(
|
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestChannelMessage(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
aliceToken := tserver.createSession("alice_msg")
|
|
bobToken := tserver.createSession("bob_msg")
|
|
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#test",
|
|
})
|
|
tserver.sendCommand(bobToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#test",
|
|
})
|
|
|
|
_, _ = tserver.pollMessages(aliceToken, 0)
|
|
_, bobLastID := tserver.pollMessages(bobToken, 0)
|
|
|
|
status, result := tserver.sendCommand(
|
|
aliceToken,
|
|
map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#test",
|
|
bodyKey: []string{"hello world"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["id"] == nil || result["id"] == "" {
|
|
t.Fatal("expected message id")
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(
|
|
bobToken, bobLastID,
|
|
)
|
|
if !findMessage(msgs, privmsgCmd, "alice_msg") {
|
|
t.Fatalf(
|
|
"bob didn't receive alice's message: %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestMessageMissingBody(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("nobody")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#test",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: privmsgCmd, toKey: "#test",
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "412") {
|
|
t.Fatalf(
|
|
"expected ERR_NOTEXTTOSEND (412), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestMessageMissingTo(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("noto")
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
bodyKey: []string{"hello"},
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "411") {
|
|
t.Fatalf(
|
|
"expected ERR_NORECIPIENT (411), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestNonMemberCannotSend(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
aliceToken := tserver.createSession("alice_nosend")
|
|
bobToken := tserver.createSession("bob_nosend")
|
|
|
|
// Only bob joins the channel.
|
|
tserver.sendCommand(bobToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#private",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(aliceToken, 0)
|
|
|
|
// Alice tries to send without joining.
|
|
status, _ := tserver.sendCommand(
|
|
aliceToken,
|
|
map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#private",
|
|
bodyKey: []string{"sneaky"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
|
|
|
if !findNumeric(msgs, "404") {
|
|
t.Fatalf(
|
|
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestDirectMessage(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
aliceToken := tserver.createSession("alice_dm")
|
|
bobToken := tserver.createSession("bob_dm")
|
|
|
|
status, result := tserver.sendCommand(
|
|
aliceToken,
|
|
map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "bob_dm",
|
|
bodyKey: []string{"hey bob"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(bobToken, 0)
|
|
if !findMessage(msgs, privmsgCmd, "alice_dm") {
|
|
t.Fatal("bob didn't receive DM")
|
|
}
|
|
|
|
aliceMsgs, _ := tserver.pollMessages(aliceToken, 0)
|
|
|
|
found := false
|
|
|
|
for _, msg := range aliceMsgs {
|
|
if msg[commandKey] == privmsgCmd &&
|
|
msg["from"] == "alice_dm" &&
|
|
msg[toKey] == "bob_dm" {
|
|
found = true
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
t.Fatal("alice didn't get DM echo")
|
|
}
|
|
}
|
|
|
|
func TestDMToNonexistentUser(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("dmsender")
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "nobody",
|
|
bodyKey: []string{"hello?"},
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "401") {
|
|
t.Fatalf(
|
|
"expected ERR_NOSUCHNICK (401), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestNickChange(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("nick_test")
|
|
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: "NICK",
|
|
bodyKey: []string{"newnick"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["nick"] != "newnick" {
|
|
t.Fatalf(
|
|
"expected newnick, got %v", result["nick"],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestNickSameAsCurrent(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("same_nick")
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: "NICK",
|
|
bodyKey: []string{"same_nick"},
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestNickCollision(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("nickuser")
|
|
|
|
tserver.createSession("taken_nick")
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: "NICK",
|
|
bodyKey: []string{"taken_nick"},
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "433") {
|
|
t.Fatalf(
|
|
"expected ERR_NICKNAMEINUSE (433), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestNickInvalid(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("nickval")
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: "NICK",
|
|
bodyKey: []string{"bad nick!"},
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "432") {
|
|
t.Fatalf(
|
|
"expected ERR_ERRONEUSNICKNAME (432), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestNickEmptyBody(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("nicknobody")
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
status, _ := tserver.sendCommand(
|
|
token, map[string]any{commandKey: "NICK"},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "461") {
|
|
t.Fatalf(
|
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestTopic(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("topic_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#topictest",
|
|
})
|
|
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: "TOPIC",
|
|
toKey: "#topictest",
|
|
bodyKey: []string{"Hello World Topic"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["topic"] != "Hello World Topic" {
|
|
t.Fatalf(
|
|
"expected topic, got %v", result["topic"],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestTopicMissingTo(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("topicnoto")
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: "TOPIC",
|
|
bodyKey: []string{"topic"},
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "461") {
|
|
t.Fatalf(
|
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestTopicMissingBody(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("topicnobody")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#topictest",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: "TOPIC", toKey: "#topictest",
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "461") {
|
|
t.Fatalf(
|
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestTopicNonMember(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
aliceToken := tserver.createSession("alice_topic")
|
|
bobToken := tserver.createSession("bob_topic")
|
|
|
|
// Only alice joins the channel.
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#topicpriv",
|
|
})
|
|
|
|
// Drain bob's initial messages.
|
|
_, lastID := tserver.pollMessages(bobToken, 0)
|
|
|
|
// Bob tries to set topic without joining.
|
|
status, _ := tserver.sendCommand(
|
|
bobToken,
|
|
map[string]any{
|
|
commandKey: "TOPIC",
|
|
toKey: "#topicpriv",
|
|
bodyKey: []string{"Hijacked topic"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(bobToken, lastID)
|
|
|
|
if !findNumeric(msgs, "442") {
|
|
t.Fatalf(
|
|
"expected ERR_NOTONCHANNEL (442), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestPing(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("ping_user")
|
|
|
|
status, result := tserver.sendCommand(
|
|
token, map[string]any{commandKey: "PING"},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
if result[commandKey] != "PONG" {
|
|
t.Fatalf(
|
|
"expected PONG, got %v",
|
|
result[commandKey],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestQuit(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("quitter")
|
|
observerToken := tserver.createSession("observer")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#quitchan",
|
|
})
|
|
tserver.sendCommand(observerToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#quitchan",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(observerToken, 0)
|
|
|
|
status, result := tserver.sendCommand(
|
|
token, map[string]any{commandKey: "QUIT"},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(
|
|
observerToken, lastID,
|
|
)
|
|
if !findMessage(msgs, "QUIT", "quitter") {
|
|
t.Fatalf(
|
|
"observer didn't get QUIT: %v", msgs,
|
|
)
|
|
}
|
|
|
|
afterStatus, _ := tserver.getState(token)
|
|
if afterStatus != http.StatusUnauthorized {
|
|
t.Fatalf(
|
|
"expected 401 after quit, got %d",
|
|
afterStatus,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestUnknownCommand(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("cmdtest")
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
status, _ := tserver.sendCommand(
|
|
token, map[string]any{commandKey: "BOGUS"},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "421") {
|
|
t.Fatalf(
|
|
"expected ERR_UNKNOWNCOMMAND (421), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestEmptyCommand(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("emptycmd")
|
|
|
|
status, _ := tserver.sendCommand(
|
|
token, map[string]any{commandKey: ""},
|
|
)
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestHistory(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("historian")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#history",
|
|
})
|
|
|
|
for range 5 {
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#history",
|
|
bodyKey: []string{"test message"},
|
|
})
|
|
}
|
|
|
|
histURL := tserver.url(
|
|
"/api/v1/history?target=%23history&limit=3",
|
|
)
|
|
|
|
resp, err := doRequestAuth(
|
|
t, http.MethodGet, histURL, token, nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
|
|
var msgs []map[string]any
|
|
|
|
decErr := json.NewDecoder(resp.Body).Decode(&msgs)
|
|
if decErr != nil {
|
|
t.Fatalf("decode history: %v", decErr)
|
|
}
|
|
|
|
if len(msgs) != 3 {
|
|
t.Fatalf("expected 3 messages, got %d", len(msgs))
|
|
}
|
|
}
|
|
|
|
func TestHistoryNonMember(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
aliceToken := tserver.createSession("alice_hist")
|
|
bobToken := tserver.createSession("bob_hist")
|
|
|
|
// Alice creates and joins a channel.
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#secret",
|
|
})
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#secret",
|
|
bodyKey: []string{"secret stuff"},
|
|
})
|
|
|
|
// Bob tries to read history without joining.
|
|
histURL := tserver.url(
|
|
"/api/v1/history?target=%23secret",
|
|
)
|
|
|
|
resp, err := doRequestAuth(
|
|
t, http.MethodGet, histURL, bobToken, nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf(
|
|
"expected 403, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestChannelList(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("lister")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#listchan",
|
|
})
|
|
|
|
resp, err := doRequestAuth(
|
|
t,
|
|
http.MethodGet,
|
|
tserver.url("/api/v1/channels"),
|
|
token,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
|
|
var channels []map[string]any
|
|
|
|
decErr := json.NewDecoder(resp.Body).Decode(
|
|
&channels,
|
|
)
|
|
if decErr != nil {
|
|
t.Fatalf("decode channels: %v", decErr)
|
|
}
|
|
|
|
found := false
|
|
|
|
for _, channel := range channels {
|
|
if channel["name"] == "#listchan" {
|
|
found = true
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
t.Fatal("channel not in list")
|
|
}
|
|
}
|
|
|
|
func TestChannelMembers(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("membertest")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#members",
|
|
})
|
|
|
|
resp, err := doRequestAuth(
|
|
t,
|
|
http.MethodGet,
|
|
tserver.url(
|
|
"/api/v1/channels/members/members",
|
|
),
|
|
token,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestLongPoll(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
aliceToken := tserver.createSession("lp_alice")
|
|
bobToken := tserver.createSession("lp_bob")
|
|
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#longpoll",
|
|
})
|
|
tserver.sendCommand(bobToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#longpoll",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(bobToken, 0)
|
|
|
|
var waitGroup sync.WaitGroup
|
|
|
|
var pollMsgs []map[string]any
|
|
|
|
waitGroup.Add(1)
|
|
|
|
go func() {
|
|
defer waitGroup.Done()
|
|
|
|
pollURL := fmt.Sprintf(
|
|
"%s"+apiMessages+"?timeout=5&after=%d",
|
|
tserver.httpServer.URL, lastID,
|
|
)
|
|
|
|
resp, err := doRequestAuth(
|
|
t, http.MethodGet,
|
|
pollURL, bobToken, nil,
|
|
)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
var result struct {
|
|
Messages []map[string]any `json:"messages"`
|
|
}
|
|
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
|
|
pollMsgs = result.Messages
|
|
}()
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#longpoll",
|
|
bodyKey: []string{"wake up!"},
|
|
})
|
|
|
|
waitGroup.Wait()
|
|
|
|
if !findMessage(pollMsgs, privmsgCmd, "lp_alice") {
|
|
t.Fatalf(
|
|
"long-poll didn't receive message: %v",
|
|
pollMsgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestLongPollTimeout(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("lp_timeout")
|
|
|
|
// Drain initial welcome/MOTD numerics.
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
start := time.Now()
|
|
|
|
resp, err := doRequestAuth(
|
|
t,
|
|
http.MethodGet,
|
|
tserver.url(fmt.Sprintf(
|
|
"%s?timeout=1&after=%d",
|
|
apiMessages, lastID,
|
|
)),
|
|
token,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
elapsed := time.Since(start)
|
|
|
|
if elapsed < 900*time.Millisecond {
|
|
t.Fatalf(
|
|
"long-poll returned too fast: %v", elapsed,
|
|
)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestEphemeralChannelCleanup(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("ephemeral")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#ephemeral",
|
|
})
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "PART", toKey: "#ephemeral",
|
|
})
|
|
|
|
resp, err := doRequestAuth(
|
|
t,
|
|
http.MethodGet,
|
|
tserver.url("/api/v1/channels"),
|
|
token,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
var channels []map[string]any
|
|
|
|
decErr := json.NewDecoder(resp.Body).Decode(
|
|
&channels,
|
|
)
|
|
if decErr != nil {
|
|
t.Fatalf("decode channels: %v", decErr)
|
|
}
|
|
|
|
for _, channel := range channels {
|
|
if channel["name"] == "#ephemeral" {
|
|
t.Fatal(
|
|
"ephemeral channel should be cleaned up",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConcurrentSessions(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
var waitGroup sync.WaitGroup
|
|
|
|
const concurrency = 20
|
|
|
|
errs := make(chan error, concurrency)
|
|
|
|
for idx := range concurrency {
|
|
waitGroup.Add(1)
|
|
|
|
go func(index int) {
|
|
defer waitGroup.Done()
|
|
|
|
nick := fmt.Sprintf("conc_%d", index)
|
|
|
|
body, err := json.Marshal(
|
|
map[string]string{"nick": nick},
|
|
)
|
|
if err != nil {
|
|
errs <- fmt.Errorf(
|
|
"marshal: %w", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodPost,
|
|
tserver.url(apiSession),
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
errs <- err
|
|
|
|
return
|
|
}
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
errs <- fmt.Errorf( //nolint:err113
|
|
"status %d for %s",
|
|
resp.StatusCode, nick,
|
|
)
|
|
}
|
|
}(idx)
|
|
}
|
|
|
|
waitGroup.Wait()
|
|
close(errs)
|
|
|
|
for err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("concurrent error: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServerInfo(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodGet,
|
|
tserver.url("/api/v1/server"),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestHealthcheck(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodGet,
|
|
tserver.url("/.well-known/healthcheck.json"),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
|
|
var result map[string]any
|
|
|
|
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
|
if decErr != nil {
|
|
t.Fatalf("decode healthcheck: %v", decErr)
|
|
}
|
|
|
|
if result[statusKey] != "ok" {
|
|
t.Fatalf(
|
|
"expected ok status, got %v",
|
|
result[statusKey],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestHealthcheckRuntimeStatsFields(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodGet,
|
|
tserver.url("/.well-known/healthcheck.json"),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
|
|
var result map[string]any
|
|
|
|
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
|
if decErr != nil {
|
|
t.Fatalf("decode healthcheck: %v", decErr)
|
|
}
|
|
|
|
requiredFields := []string{
|
|
"sessions", "clients", "queuedLines",
|
|
"channels", "connectionsSinceBoot",
|
|
"sessionsSinceBoot", "messagesSinceBoot",
|
|
}
|
|
|
|
for _, field := range requiredFields {
|
|
if _, ok := result[field]; !ok {
|
|
t.Errorf(
|
|
"missing field %q in healthcheck", field,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHealthcheckRuntimeStatsValues(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("statsuser")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#statschan",
|
|
})
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#statschan",
|
|
bodyKey: []string{"hello stats"},
|
|
})
|
|
|
|
result := tserver.fetchHealthcheck(t)
|
|
|
|
assertFieldGTE(t, result, "sessions", 1)
|
|
assertFieldGTE(t, result, "clients", 1)
|
|
assertFieldGTE(t, result, "channels", 1)
|
|
assertFieldGTE(t, result, "queuedLines", 0)
|
|
assertFieldGTE(t, result, "sessionsSinceBoot", 1)
|
|
assertFieldGTE(t, result, "connectionsSinceBoot", 1)
|
|
assertFieldGTE(t, result, "messagesSinceBoot", 1)
|
|
}
|
|
|
|
func (tserver *testServer) fetchHealthcheck(
|
|
t *testing.T,
|
|
) map[string]any {
|
|
t.Helper()
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodGet,
|
|
tserver.url("/.well-known/healthcheck.json"),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
|
|
var result map[string]any
|
|
|
|
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
|
if decErr != nil {
|
|
t.Fatalf("decode healthcheck: %v", decErr)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func assertFieldGTE(
|
|
t *testing.T,
|
|
result map[string]any,
|
|
field string,
|
|
minimum float64,
|
|
) {
|
|
t.Helper()
|
|
|
|
val, ok := result[field].(float64)
|
|
if !ok {
|
|
t.Errorf(
|
|
"field %q: not a number (got %T)",
|
|
field, result[field],
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if val < minimum {
|
|
t.Errorf(
|
|
"expected %s >= %v, got %v",
|
|
field, minimum, val,
|
|
)
|
|
}
|
|
}
|
|
|
|
func postJSONExpectStatus(
|
|
t *testing.T,
|
|
tserver *testServer,
|
|
path string,
|
|
payload map[string]string,
|
|
expectedStatus int,
|
|
) {
|
|
t.Helper()
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodPost,
|
|
tserver.url(path),
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != expectedStatus {
|
|
t.Fatalf(
|
|
"expected %d, got %d",
|
|
expectedStatus, resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestPassCommand(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("passuser")
|
|
|
|
// Drain initial messages.
|
|
_, _ = tserver.pollMessages(token, 0)
|
|
|
|
// Set password via PASS command.
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: "PASS",
|
|
bodyKey: []string{"s3cure_pass"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result[statusKey] != "ok" {
|
|
t.Fatalf(
|
|
"expected ok, got %v", result[statusKey],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestPassCommandShortPassword(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("shortpw")
|
|
|
|
// Drain initial messages.
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Try short password — should fail.
|
|
status, _ := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: "PASS",
|
|
bodyKey: []string{"short"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "461") {
|
|
t.Fatalf(
|
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestPassCommandEmpty(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("emptypw")
|
|
|
|
// Drain initial messages.
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Try empty password — should fail.
|
|
status, _ := tserver.sendCommand(
|
|
token,
|
|
map[string]any{commandKey: "PASS"},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "461") {
|
|
t.Fatalf(
|
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestLoginValid(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
// Create session and set password via PASS command.
|
|
token := tserver.createSession("loginuser")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "PASS",
|
|
bodyKey: []string{"password123"},
|
|
})
|
|
|
|
// Login with nick + password.
|
|
loginBody, err := json.Marshal(map[string]string{
|
|
"nick": "loginuser", "password": "password123",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodPost,
|
|
tserver.url("/api/v1/login"),
|
|
bytes.NewReader(loginBody),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf(
|
|
"expected 200, got %d: %s",
|
|
resp.StatusCode, respBody,
|
|
)
|
|
}
|
|
|
|
// Extract auth cookie from login response.
|
|
var loginCookie string
|
|
|
|
for _, cookie := range resp.Cookies() {
|
|
if cookie.Name == authCookieName {
|
|
loginCookie = cookie.Value
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if loginCookie == "" {
|
|
t.Fatal("expected auth cookie from login")
|
|
}
|
|
|
|
// Verify login cookie works for auth.
|
|
status, state := tserver.getState(loginCookie)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
if state["nick"] != "loginuser" {
|
|
t.Fatalf(
|
|
"expected loginuser, got %v",
|
|
state["nick"],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestLoginWrongPassword(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
// Create session and set password via PASS command.
|
|
token := tserver.createSession("wrongpwuser")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "PASS",
|
|
bodyKey: []string{"correctpass1"},
|
|
})
|
|
|
|
postJSONExpectStatus(
|
|
t, tserver, "/api/v1/login",
|
|
map[string]string{
|
|
"nick": "wrongpwuser",
|
|
"password": "wrongpass12",
|
|
},
|
|
http.StatusUnauthorized,
|
|
)
|
|
}
|
|
|
|
func TestLoginNonexistentUser(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
postJSONExpectStatus(
|
|
t, tserver, "/api/v1/login",
|
|
map[string]string{
|
|
"nick": "ghostuser",
|
|
"password": "password123",
|
|
},
|
|
http.StatusUnauthorized,
|
|
)
|
|
}
|
|
|
|
func TestSessionCookie(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
body, err := json.Marshal(
|
|
map[string]string{"nick": "cookietest"},
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodPost,
|
|
tserver.url(apiSession),
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf(
|
|
"expected 201, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
|
|
// Verify Set-Cookie header.
|
|
var authCookie *http.Cookie
|
|
|
|
for _, cookie := range resp.Cookies() {
|
|
if cookie.Name == authCookieName {
|
|
authCookie = cookie
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if authCookie == nil {
|
|
t.Fatal("expected neoirc_auth cookie")
|
|
}
|
|
|
|
if !authCookie.HttpOnly {
|
|
t.Fatal("cookie should be HttpOnly")
|
|
}
|
|
|
|
if authCookie.SameSite != http.SameSiteStrictMode {
|
|
t.Fatal("cookie should be SameSite=Strict")
|
|
}
|
|
|
|
// Verify JSON body does NOT contain token.
|
|
var result map[string]any
|
|
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
|
|
if _, hasToken := result["token"]; hasToken {
|
|
t.Fatal("JSON body should not contain token")
|
|
}
|
|
}
|
|
|
|
func TestSessionStillWorks(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
// Verify anonymous session creation still works.
|
|
token := tserver.createSession("anon_user")
|
|
if token == "" {
|
|
t.Fatal("expected cookie for anonymous session")
|
|
}
|
|
|
|
status, state := tserver.getState(token)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
if state["nick"] != "anon_user" {
|
|
t.Fatalf(
|
|
"expected anon_user, got %v",
|
|
state["nick"],
|
|
)
|
|
}
|
|
}
|
|
|
|
// findNumericWithParams returns the first message matching
|
|
// the given numeric code. Returns nil if not found.
|
|
func findNumericWithParams(
|
|
msgs []map[string]any,
|
|
numeric string,
|
|
) map[string]any {
|
|
want, _ := strconv.Atoi(numeric)
|
|
|
|
for _, msg := range msgs {
|
|
code, ok := msg["code"].(float64)
|
|
if ok && int(code) == want {
|
|
return msg
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getNumericParams extracts the params array from a
|
|
// numeric message as a string slice.
|
|
func getNumericParams(
|
|
msg map[string]any,
|
|
) []string {
|
|
raw, exists := msg["params"]
|
|
if !exists || raw == nil {
|
|
return nil
|
|
}
|
|
|
|
arr, isArr := raw.([]any)
|
|
if !isArr {
|
|
return nil
|
|
}
|
|
|
|
result := make([]string, 0, len(arr))
|
|
|
|
for _, val := range arr {
|
|
str, isString := val.(string)
|
|
if isString {
|
|
result = append(result, str)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func TestWhoisShowsHostInfo(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSessionWithUsername(
|
|
"whoisuser", "myident",
|
|
)
|
|
|
|
queryToken := tserver.createSession("querier")
|
|
|
|
_, lastID := tserver.pollMessages(queryToken, 0)
|
|
|
|
tserver.sendCommand(queryToken, map[string]any{
|
|
commandKey: "WHOIS",
|
|
toKey: "whoisuser",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(queryToken, lastID)
|
|
|
|
whoisMsg := findNumericWithParams(msgs, "311")
|
|
if whoisMsg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_WHOISUSER (311), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
params := getNumericParams(whoisMsg)
|
|
|
|
if len(params) < 2 {
|
|
t.Fatalf(
|
|
"expected at least 2 params, got %v",
|
|
params,
|
|
)
|
|
}
|
|
|
|
if params[1] != "myident" {
|
|
t.Fatalf(
|
|
"expected username myident, got %s",
|
|
params[1],
|
|
)
|
|
}
|
|
|
|
_ = token
|
|
}
|
|
|
|
// createSessionWithUsername creates a session with a
|
|
// specific username and returns the auth cookie value.
|
|
func (tserver *testServer) createSessionWithUsername(
|
|
nick, username string,
|
|
) string {
|
|
tserver.t.Helper()
|
|
|
|
body, err := json.Marshal(map[string]string{
|
|
"nick": nick,
|
|
"username": username,
|
|
})
|
|
if err != nil {
|
|
tserver.t.Fatalf("marshal session: %v", err)
|
|
}
|
|
|
|
resp, err := doRequest(
|
|
tserver.t,
|
|
http.MethodPost,
|
|
tserver.url(apiSession),
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
tserver.t.Fatalf("create session: %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
tserver.t.Fatalf(
|
|
"create session: status %d: %s",
|
|
resp.StatusCode, respBody,
|
|
)
|
|
}
|
|
|
|
// Drain the body.
|
|
_, _ = io.ReadAll(resp.Body)
|
|
|
|
// Extract auth cookie from response.
|
|
for _, cookie := range resp.Cookies() {
|
|
if cookie.Name == authCookieName {
|
|
return cookie.Value
|
|
}
|
|
}
|
|
|
|
tserver.t.Fatal("no auth cookie in response")
|
|
|
|
return ""
|
|
}
|
|
|
|
func TestWhoShowsHostInfo(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
whoToken := tserver.createSessionWithUsername(
|
|
"whouser", "whoident",
|
|
)
|
|
|
|
tserver.sendCommand(whoToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#whotest",
|
|
})
|
|
|
|
queryToken := tserver.createSession("whoquerier")
|
|
|
|
tserver.sendCommand(queryToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#whotest",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(queryToken, 0)
|
|
|
|
tserver.sendCommand(queryToken, map[string]any{
|
|
commandKey: "WHO",
|
|
toKey: "#whotest",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(queryToken, lastID)
|
|
|
|
assertWhoReplyUsername(t, msgs, "whouser", "whoident")
|
|
}
|
|
|
|
func assertWhoReplyUsername(
|
|
t *testing.T,
|
|
msgs []map[string]any,
|
|
targetNick, expectedUsername string,
|
|
) {
|
|
t.Helper()
|
|
|
|
for _, msg := range msgs {
|
|
code, isCode := msg["code"].(float64)
|
|
if !isCode || int(code) != 352 {
|
|
continue
|
|
}
|
|
|
|
params := getNumericParams(msg)
|
|
if len(params) < 5 || params[4] != targetNick {
|
|
continue
|
|
}
|
|
|
|
if params[1] != expectedUsername {
|
|
t.Fatalf(
|
|
"expected username %s in WHO, got %s",
|
|
expectedUsername, params[1],
|
|
)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
t.Fatalf(
|
|
"expected RPL_WHOREPLY (352) for %s, msgs: %v",
|
|
targetNick, msgs,
|
|
)
|
|
}
|
|
|
|
func TestSessionUsernameDefault(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
// Create session without specifying username.
|
|
token := tserver.createSession("defaultusr")
|
|
|
|
queryToken := tserver.createSession("querier2")
|
|
|
|
_, lastID := tserver.pollMessages(queryToken, 0)
|
|
|
|
// WHOIS should show the nick as the username.
|
|
tserver.sendCommand(queryToken, map[string]any{
|
|
commandKey: "WHOIS",
|
|
toKey: "defaultusr",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(queryToken, lastID)
|
|
|
|
whoisMsg := findNumericWithParams(msgs, "311")
|
|
if whoisMsg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_WHOISUSER (311), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
params := getNumericParams(whoisMsg)
|
|
|
|
if len(params) < 2 {
|
|
t.Fatalf(
|
|
"expected at least 2 params, got %v",
|
|
params,
|
|
)
|
|
}
|
|
|
|
// Username defaults to nick.
|
|
if params[1] != "defaultusr" {
|
|
t.Fatalf(
|
|
"expected default username defaultusr, got %s",
|
|
params[1],
|
|
)
|
|
}
|
|
|
|
_ = token
|
|
}
|
|
|
|
func TestLoginRateLimitExceeded(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
// Exhaust the burst (default: 5 per IP) using
|
|
// nonexistent users. These fail fast (no bcrypt),
|
|
// preventing token replenishment between requests.
|
|
for range 5 {
|
|
loginBody, mErr := json.Marshal(
|
|
map[string]string{
|
|
"nick": "nosuchuser",
|
|
"password": "doesnotmatter",
|
|
},
|
|
)
|
|
if mErr != nil {
|
|
t.Fatal(mErr)
|
|
}
|
|
|
|
loginResp, rErr := doRequest(
|
|
t,
|
|
http.MethodPost,
|
|
tserver.url("/api/v1/login"),
|
|
bytes.NewReader(loginBody),
|
|
)
|
|
if rErr != nil {
|
|
t.Fatal(rErr)
|
|
}
|
|
|
|
_ = loginResp.Body.Close()
|
|
}
|
|
|
|
// The next request should be rate-limited.
|
|
loginBody, err := json.Marshal(map[string]string{
|
|
"nick": "nosuchuser", "password": "doesnotmatter",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodPost,
|
|
tserver.url("/api/v1/login"),
|
|
bytes.NewReader(loginBody),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusTooManyRequests {
|
|
t.Fatalf(
|
|
"expected 429, got %d",
|
|
resp.StatusCode,
|
|
)
|
|
}
|
|
|
|
retryAfter := resp.Header.Get("Retry-After")
|
|
if retryAfter == "" {
|
|
t.Fatal("expected Retry-After header")
|
|
}
|
|
}
|
|
|
|
func TestLoginRateLimitAllowsNormalUse(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
// Create session and set password via PASS command.
|
|
token := tserver.createSession("normaluser")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "PASS",
|
|
bodyKey: []string{"password123"},
|
|
})
|
|
|
|
// A single login should succeed without rate limiting.
|
|
loginBody, err := json.Marshal(map[string]string{
|
|
"nick": "normaluser", "password": "password123",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resp2, err := doRequest(
|
|
t,
|
|
http.MethodPost,
|
|
tserver.url("/api/v1/login"),
|
|
bytes.NewReader(loginBody),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp2.Body.Close() }()
|
|
|
|
if resp2.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp2.Body)
|
|
t.Fatalf(
|
|
"expected 200, got %d: %s",
|
|
resp2.StatusCode, respBody,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestNickBroadcastToChannels(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
aliceToken := tserver.createSession("nick_a")
|
|
bobToken := tserver.createSession("nick_b")
|
|
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#nicktest",
|
|
})
|
|
tserver.sendCommand(bobToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#nicktest",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(bobToken, 0)
|
|
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: "NICK",
|
|
bodyKey: []string{"nick_a_new"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(bobToken, lastID)
|
|
|
|
if !findMessage(msgs, "NICK", "nick_a") {
|
|
t.Fatalf(
|
|
"bob didn't get nick change: %v", msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// --- Channel Hashcash Tests ---
|
|
|
|
const (
|
|
metaKey = "meta"
|
|
modeCmd = "MODE"
|
|
hashcashKey = "hashcash"
|
|
)
|
|
|
|
func mintTestChannelHashcash(
|
|
tb testing.TB,
|
|
bits int,
|
|
channel string,
|
|
body json.RawMessage,
|
|
) string {
|
|
tb.Helper()
|
|
|
|
bodyHash := hashcash.BodyHash(body)
|
|
|
|
return hashcash.MintChannelStamp(bits, channel, bodyHash)
|
|
}
|
|
|
|
func TestChannelHashcashSetMode(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("hcmode_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#hctest",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Set hashcash bits to 2 via MODE +H.
|
|
status, _ := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#hctest",
|
|
bodyKey: []string{"+H", "2"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Should get RPL_CHANNELMODEIS (324) confirming +H.
|
|
if !findNumeric(msgs, "324") {
|
|
t.Fatalf(
|
|
"expected RPL_CHANNELMODEIS (324), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestChannelHashcashQueryMode(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("hcquery_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#hcquery",
|
|
})
|
|
|
|
// Set hashcash bits.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#hcquery",
|
|
bodyKey: []string{"+H", "5"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Query mode — should show +nH.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#hcquery",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
found := false
|
|
|
|
for _, msg := range msgs {
|
|
code, ok := msg["code"].(float64)
|
|
if ok && int(code) == 324 {
|
|
found = true
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
t.Fatalf(
|
|
"expected RPL_CHANNELMODEIS (324), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestChannelHashcashClearMode(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("hcclear_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#hcclear",
|
|
})
|
|
|
|
// Set hashcash bits.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#hcclear",
|
|
bodyKey: []string{"+H", "5"},
|
|
})
|
|
|
|
// Clear hashcash bits.
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#hcclear",
|
|
bodyKey: []string{"-H"},
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
// Now message should succeed without hashcash.
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#hcclear",
|
|
bodyKey: []string{"test message"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestChannelHashcashRejectNoStamp(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("hcreject_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#hcreject",
|
|
})
|
|
|
|
// Set hashcash requirement.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#hcreject",
|
|
bodyKey: []string{"+H", "2"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Send message without hashcash — should fail.
|
|
status, _ := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#hcreject",
|
|
bodyKey: []string{"spam message"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Should get ERR_CANNOTSENDTOCHAN (404).
|
|
if !findNumeric(msgs, "404") {
|
|
t.Fatalf(
|
|
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestChannelHashcashAcceptValidStamp(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("hcaccept_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#hcaccept",
|
|
})
|
|
|
|
// Set hashcash requirement (2 bits = fast to mint).
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#hcaccept",
|
|
bodyKey: []string{"+H", "2"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Mint a valid hashcash stamp.
|
|
msgBody, marshalErr := json.Marshal(
|
|
[]string{"hello world"},
|
|
)
|
|
if marshalErr != nil {
|
|
t.Fatal(marshalErr)
|
|
}
|
|
|
|
stamp := mintTestChannelHashcash(
|
|
t, 2, "#hcaccept", msgBody,
|
|
)
|
|
|
|
// Send message with valid hashcash.
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#hcaccept",
|
|
bodyKey: []string{"hello world"},
|
|
metaKey: map[string]any{
|
|
hashcashKey: stamp,
|
|
},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["id"] == nil || result["id"] == "" {
|
|
t.Fatal("expected message id for valid hashcash")
|
|
}
|
|
|
|
// Verify the message was delivered.
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
if !findMessage(msgs, privmsgCmd, "hcaccept_user") {
|
|
t.Fatalf(
|
|
"message not received: %v", msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestChannelHashcashRejectReplayedStamp(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("hcreplay_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#hcreplay",
|
|
})
|
|
|
|
// Set hashcash requirement.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#hcreplay",
|
|
bodyKey: []string{"+H", "2"},
|
|
})
|
|
|
|
_, _ = tserver.pollMessages(token, 0)
|
|
|
|
// Mint and send once — should succeed.
|
|
msgBody, marshalErr := json.Marshal(
|
|
[]string{"unique msg"},
|
|
)
|
|
if marshalErr != nil {
|
|
t.Fatal(marshalErr)
|
|
}
|
|
|
|
stamp := mintTestChannelHashcash(
|
|
t, 2, "#hcreplay", msgBody,
|
|
)
|
|
|
|
status, _ := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#hcreplay",
|
|
bodyKey: []string{"unique msg"},
|
|
metaKey: map[string]any{
|
|
hashcashKey: stamp,
|
|
},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Replay the same stamp — should fail.
|
|
status, _ = tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#hcreplay",
|
|
bodyKey: []string{"unique msg"},
|
|
metaKey: map[string]any{
|
|
hashcashKey: stamp,
|
|
},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Should get ERR_CANNOTSENDTOCHAN (404).
|
|
if !findNumeric(msgs, "404") {
|
|
t.Fatalf(
|
|
"expected replay rejection (404), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestChannelHashcashNoRequirementWorks(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("hcnone_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#nohashcash",
|
|
})
|
|
|
|
// No hashcash set — message should work.
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#nohashcash",
|
|
bodyKey: []string{"free message"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["id"] == nil || result["id"] == "" {
|
|
t.Fatal("expected message id")
|
|
}
|
|
}
|
|
|
|
func TestChannelHashcashInvalidBitsRange(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("hcbits_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#hcbits",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Try to set bits to 0 — should fail.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#hcbits",
|
|
bodyKey: []string{"+H", "0"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "472") {
|
|
t.Fatalf(
|
|
"expected ERR_UNKNOWNMODE (472), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestChannelHashcashMissingBitsArg(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("hcnoarg_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#hcnoarg",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Try to set +H without bits argument.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#hcnoarg",
|
|
bodyKey: []string{"+H"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if !findNumeric(msgs, "461") {
|
|
t.Fatalf(
|
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestNamesShowsHostmask(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
queryToken, lastID := setupChannelWithIdentMember(
|
|
tserver, "namesmember", "nmident",
|
|
"namesquery", "#namestest",
|
|
)
|
|
|
|
// Issue an explicit NAMES command.
|
|
tserver.sendCommand(queryToken, map[string]any{
|
|
commandKey: "NAMES",
|
|
toKey: "#namestest",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(queryToken, lastID)
|
|
|
|
assertNamesHostmask(
|
|
t, msgs, "namesmember", "nmident",
|
|
)
|
|
}
|
|
|
|
func TestNamesOnJoinShowsHostmask(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
// First user joins to populate the channel.
|
|
firstToken := tserver.createSessionWithUsername(
|
|
"joinmem", "jmident",
|
|
)
|
|
|
|
tserver.sendCommand(firstToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#joinnamestest",
|
|
})
|
|
|
|
// Second user joins; the JOIN triggers
|
|
// deliverNamesNumerics which should include
|
|
// hostmask data.
|
|
joinerToken := tserver.createSession("joiner")
|
|
|
|
tserver.sendCommand(joinerToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#joinnamestest",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(joinerToken, 0)
|
|
|
|
assertNamesHostmask(
|
|
t, msgs, "joinmem", "jmident",
|
|
)
|
|
}
|
|
|
|
// setupChannelWithIdentMember creates a member session
|
|
// with username, joins a channel, then creates a querier
|
|
// and joins the same channel. Returns the querier token
|
|
// and last message ID.
|
|
func setupChannelWithIdentMember(
|
|
tserver *testServer,
|
|
memberNick, memberUsername,
|
|
querierNick, channel string,
|
|
) (string, int64) {
|
|
tserver.t.Helper()
|
|
|
|
memberToken := tserver.createSessionWithUsername(
|
|
memberNick, memberUsername,
|
|
)
|
|
|
|
tserver.sendCommand(memberToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
queryToken := tserver.createSession(querierNick)
|
|
|
|
tserver.sendCommand(queryToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(queryToken, 0)
|
|
|
|
return queryToken, lastID
|
|
}
|
|
|
|
// assertNamesHostmask verifies that a RPL_NAMREPLY (353)
|
|
// message contains the expected nick with hostmask format
|
|
// (nick!user@host).
|
|
func assertNamesHostmask(
|
|
t *testing.T,
|
|
msgs []map[string]any,
|
|
targetNick, expectedUsername string,
|
|
) {
|
|
t.Helper()
|
|
|
|
for _, msg := range msgs {
|
|
code, ok := msg["code"].(float64)
|
|
if !ok || int(code) != 353 {
|
|
continue
|
|
}
|
|
|
|
raw, exists := msg["body"]
|
|
if !exists || raw == nil {
|
|
continue
|
|
}
|
|
|
|
arr, isArr := raw.([]any)
|
|
if !isArr || len(arr) == 0 {
|
|
continue
|
|
}
|
|
|
|
bodyStr, isStr := arr[0].(string)
|
|
if !isStr {
|
|
continue
|
|
}
|
|
|
|
// Look for the target nick's hostmask entry.
|
|
expected := targetNick + "!" +
|
|
expectedUsername + "@"
|
|
|
|
if !strings.Contains(bodyStr, expected) {
|
|
t.Fatalf(
|
|
"expected NAMES body to contain %q, "+
|
|
"got %q",
|
|
expected, bodyStr,
|
|
)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
t.Fatalf(
|
|
"expected RPL_NAMREPLY (353) with hostmask "+
|
|
"for %s, msgs: %v",
|
|
targetNick, msgs,
|
|
)
|
|
}
|
|
|
|
const testOperName = "admin"
|
|
const testOperPassword = "secretpass"
|
|
|
|
// newTestServerWithOper creates a test server with oper
|
|
// credentials configured (admin / secretpass).
|
|
func newTestServerWithOper(
|
|
t *testing.T,
|
|
) *testServer {
|
|
t.Helper()
|
|
|
|
return newTestServerWith(
|
|
t, 0, testOperName, testOperPassword,
|
|
)
|
|
}
|
|
|
|
func TestOperCommandSuccess(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
token := tserver.createSession("operuser")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Send OPER command.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName, testOperPassword},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 381 RPL_YOUREOPER.
|
|
if !findNumeric(msgs, "381") {
|
|
t.Fatalf(
|
|
"expected RPL_YOUREOPER (381), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestOperCommandFailure(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
token := tserver.createSession("badoper")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Send OPER with wrong password.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName, "wrongpass"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 491 ERR_NOOPERHOST.
|
|
if !findNumeric(msgs, "491") {
|
|
t.Fatalf(
|
|
"expected ERR_NOOPERHOST (491), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestOperCommandNeedMoreParams(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
token := tserver.createSession("shortoper")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Send OPER with only one parameter.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 461 ERR_NEEDMOREPARAMS.
|
|
if !findNumeric(msgs, "461") {
|
|
t.Fatalf(
|
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestOperWhoisShowsClientInfo(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
// Create a target user.
|
|
_ = tserver.createSession("target")
|
|
|
|
// Create an oper user.
|
|
operToken := tserver.createSession("theoper")
|
|
_, lastID := tserver.pollMessages(operToken, 0)
|
|
|
|
// Authenticate as oper.
|
|
tserver.sendCommand(operToken, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName, testOperPassword},
|
|
})
|
|
|
|
var msgs []map[string]any
|
|
|
|
msgs, lastID = tserver.pollMessages(operToken, lastID)
|
|
|
|
if !findNumeric(msgs, "381") {
|
|
t.Fatalf(
|
|
"expected RPL_YOUREOPER (381), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
// Now WHOIS the target.
|
|
tserver.sendCommand(operToken, map[string]any{
|
|
commandKey: "WHOIS",
|
|
toKey: "target",
|
|
})
|
|
|
|
msgs, _ = tserver.pollMessages(operToken, lastID)
|
|
|
|
// Expect 338 RPL_WHOISACTUALLY with client IP.
|
|
whoisActually := findNumericWithParams(msgs, "338")
|
|
if whoisActually == nil {
|
|
t.Fatalf(
|
|
"expected RPL_WHOISACTUALLY (338) for "+
|
|
"oper WHOIS, got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
params := getNumericParams(whoisActually)
|
|
if len(params) < 2 {
|
|
t.Fatalf(
|
|
"expected at least 2 params in 338, "+
|
|
"got %v",
|
|
params,
|
|
)
|
|
}
|
|
|
|
// First param should be the target nick.
|
|
if params[0] != "target" {
|
|
t.Fatalf(
|
|
"expected first param 'target', got %s",
|
|
params[0],
|
|
)
|
|
}
|
|
|
|
// Second param should be a non-empty IP.
|
|
if params[1] == "" {
|
|
t.Fatal("expected non-empty IP in 338 params")
|
|
}
|
|
}
|
|
|
|
func TestNonOperWhoisHidesClientInfo(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
// Create a target user.
|
|
_ = tserver.createSession("hidden")
|
|
|
|
// Create a regular (non-oper) user.
|
|
regToken := tserver.createSession("regular")
|
|
_, lastID := tserver.pollMessages(regToken, 0)
|
|
|
|
// WHOIS the target without oper status.
|
|
tserver.sendCommand(regToken, map[string]any{
|
|
commandKey: "WHOIS",
|
|
toKey: "hidden",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(regToken, lastID)
|
|
|
|
// Should NOT see 338 RPL_WHOISACTUALLY.
|
|
if findNumeric(msgs, "338") {
|
|
t.Fatalf(
|
|
"non-oper should not see "+
|
|
"RPL_WHOISACTUALLY (338), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
// But should see 311 RPL_WHOISUSER (normal WHOIS).
|
|
if !findNumeric(msgs, "311") {
|
|
t.Fatalf(
|
|
"expected RPL_WHOISUSER (311), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestWhoisShowsOperatorStatus(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
// Create oper user and authenticate.
|
|
operToken := tserver.createSession("iamoper")
|
|
_, lastID := tserver.pollMessages(operToken, 0)
|
|
|
|
tserver.sendCommand(operToken, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName, testOperPassword},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(operToken, lastID)
|
|
|
|
if !findNumeric(msgs, "381") {
|
|
t.Fatalf("expected 381, got %v", msgs)
|
|
}
|
|
|
|
// Another user does WHOIS on the oper.
|
|
queryToken := tserver.createSession("asker")
|
|
_, queryLastID := tserver.pollMessages(queryToken, 0)
|
|
|
|
tserver.sendCommand(queryToken, map[string]any{
|
|
commandKey: "WHOIS",
|
|
toKey: "iamoper",
|
|
})
|
|
|
|
msgs, _ = tserver.pollMessages(queryToken, queryLastID)
|
|
|
|
// Should see 313 RPL_WHOISOPERATOR.
|
|
if !findNumeric(msgs, "313") {
|
|
t.Fatalf(
|
|
"expected RPL_WHOISOPERATOR (313) in "+
|
|
"WHOIS of oper, got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestOperNoOlineConfigured(t *testing.T) {
|
|
// Standard test server has no oper configured.
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("nooline")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName, "password"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Should get 491 since no o-line is configured.
|
|
if !findNumeric(msgs, "491") {
|
|
t.Fatalf(
|
|
"expected ERR_NOOPERHOST (491) when no "+
|
|
"o-line configured, got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// --- Tier 1: Channel Modes Tests ---
|
|
|
|
const kickCmd = "KICK"
|
|
|
|
// TestOperatorAutoGrantOnCreate verifies that the first
|
|
// user to join a channel (the creator) gets +o.
|
|
func TestOperatorAutoGrantOnCreate(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("creator")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#opcreate",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Issue NAMES — the creator should have @prefix.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "NAMES", toKey: "#opcreate",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
namesMsg := findNumericWithParams(msgs, "353")
|
|
if namesMsg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_NAMREPLY (353), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
raw, exists := namesMsg["body"]
|
|
if !exists || raw == nil {
|
|
t.Fatal("expected body in NAMES reply")
|
|
}
|
|
|
|
arr, isArr := raw.([]any)
|
|
if !isArr || len(arr) == 0 {
|
|
t.Fatal("expected non-empty body array")
|
|
}
|
|
|
|
bodyStr, isStr := arr[0].(string)
|
|
if !isStr {
|
|
t.Fatal("expected string body")
|
|
}
|
|
|
|
if !strings.Contains(bodyStr, "@creator!") {
|
|
t.Fatalf(
|
|
"expected @creator in NAMES, got %q",
|
|
bodyStr,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestSecondJoinerNotOperator verifies that subsequent
|
|
// joiners do NOT get +o.
|
|
func TestSecondJoinerNotOperator(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
creatorToken := tserver.createSession("op_first")
|
|
joinerToken := tserver.createSession("op_second")
|
|
|
|
tserver.sendCommand(creatorToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#optest2",
|
|
})
|
|
|
|
tserver.sendCommand(joinerToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#optest2",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(joinerToken, 0)
|
|
|
|
tserver.sendCommand(joinerToken, map[string]any{
|
|
commandKey: "NAMES", toKey: "#optest2",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(joinerToken, lastID)
|
|
|
|
namesMsg := findNumericWithParams(msgs, "353")
|
|
if namesMsg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_NAMREPLY (353), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
raw := namesMsg["body"]
|
|
|
|
arr, _ := raw.([]any)
|
|
|
|
bodyStr, _ := arr[0].(string)
|
|
|
|
// op_first should have @, op_second should not.
|
|
if !strings.Contains(bodyStr, "@op_first!") {
|
|
t.Fatalf(
|
|
"expected @op_first in NAMES, got %q",
|
|
bodyStr,
|
|
)
|
|
}
|
|
|
|
if strings.Contains(bodyStr, "@op_second!") {
|
|
t.Fatalf(
|
|
"op_second should NOT have @, got %q",
|
|
bodyStr,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestModeGrantOperator tests MODE +o.
|
|
func TestModeGrantOperator(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("granter")
|
|
targetToken := tserver.createSession("grantee")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#modeop",
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#modeop",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(targetToken, 0)
|
|
|
|
// granter (creator = +o) grants +o to grantee.
|
|
status, _ := tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#modeop",
|
|
bodyKey: []string{"+o", "grantee"},
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
// grantee should receive MODE broadcast.
|
|
msgs, _ := tserver.pollMessages(targetToken, lastID)
|
|
|
|
if !findMessage(msgs, modeCmd, "granter") {
|
|
t.Fatalf(
|
|
"grantee didn't get MODE broadcast: %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
// Verify grantee now shows @ in NAMES.
|
|
_, lastID = tserver.pollMessages(targetToken, 0)
|
|
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: "NAMES", toKey: "#modeop",
|
|
})
|
|
|
|
msgs, _ = tserver.pollMessages(targetToken, lastID)
|
|
|
|
namesMsg := findNumericWithParams(msgs, "353")
|
|
if namesMsg == nil {
|
|
t.Fatal("expected NAMES reply")
|
|
}
|
|
|
|
raw := namesMsg["body"]
|
|
arr, _ := raw.([]any)
|
|
bodyStr, _ := arr[0].(string)
|
|
|
|
if !strings.Contains(bodyStr, "@grantee!") {
|
|
t.Fatalf(
|
|
"expected @grantee in NAMES, got %q",
|
|
bodyStr,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestModeRevokeOperator tests MODE -o.
|
|
func TestModeRevokeOperator(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("revoker")
|
|
targetToken := tserver.createSession("revokee")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#revoke",
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#revoke",
|
|
})
|
|
|
|
// Grant +o, then revoke it.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#revoke",
|
|
bodyKey: []string{"+o", "revokee"},
|
|
})
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#revoke",
|
|
bodyKey: []string{"-o", "revokee"},
|
|
})
|
|
|
|
// Check NAMES — revokee should NOT have @.
|
|
_, lastID := tserver.pollMessages(opToken, 0)
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: "NAMES", toKey: "#revoke",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(opToken, lastID)
|
|
|
|
namesMsg := findNumericWithParams(msgs, "353")
|
|
if namesMsg == nil {
|
|
t.Fatal("expected NAMES reply")
|
|
}
|
|
|
|
raw := namesMsg["body"]
|
|
arr, _ := raw.([]any)
|
|
bodyStr, _ := arr[0].(string)
|
|
|
|
if strings.Contains(bodyStr, "@revokee!") {
|
|
t.Fatalf(
|
|
"revokee should NOT have @ after -o, got %q",
|
|
bodyStr,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestModeVoice tests MODE +v/-v.
|
|
func TestModeVoice(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("voicer")
|
|
targetToken := tserver.createSession("voiced")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#voice",
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#voice",
|
|
})
|
|
|
|
// Grant +v.
|
|
status, _ := tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#voice",
|
|
bodyKey: []string{"+v", "voiced"},
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
// Check NAMES for + prefix.
|
|
_, lastID := tserver.pollMessages(opToken, 0)
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: "NAMES", toKey: "#voice",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(opToken, lastID)
|
|
|
|
namesMsg := findNumericWithParams(msgs, "353")
|
|
raw := namesMsg["body"]
|
|
arr, _ := raw.([]any)
|
|
bodyStr, _ := arr[0].(string)
|
|
|
|
if !strings.Contains(bodyStr, "+voiced!") {
|
|
t.Fatalf(
|
|
"expected +voiced in NAMES, got %q",
|
|
bodyStr,
|
|
)
|
|
}
|
|
|
|
// Revoke -v.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#voice",
|
|
bodyKey: []string{"-v", "voiced"},
|
|
})
|
|
|
|
_, lastID = tserver.pollMessages(opToken, 0)
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: "NAMES", toKey: "#voice",
|
|
})
|
|
|
|
msgs, _ = tserver.pollMessages(opToken, lastID)
|
|
|
|
namesMsg = findNumericWithParams(msgs, "353")
|
|
raw = namesMsg["body"]
|
|
arr, _ = raw.([]any)
|
|
bodyStr, _ = arr[0].(string)
|
|
|
|
if strings.Contains(bodyStr, "+voiced!") {
|
|
t.Fatalf(
|
|
"voiced should NOT have + after -v, got %q",
|
|
bodyStr,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestModeNonOpCannotGrant verifies non-operators
|
|
// cannot grant +o or +v.
|
|
func TestModeNonOpCannotGrant(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("realop")
|
|
nonOpToken := tserver.createSession("nonop")
|
|
targetToken := tserver.createSession("target3")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#noperm",
|
|
})
|
|
tserver.sendCommand(nonOpToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#noperm",
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#noperm",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(nonOpToken, 0)
|
|
|
|
// Non-op tries +o.
|
|
tserver.sendCommand(nonOpToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#noperm",
|
|
bodyKey: []string{"+o", "target3"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(nonOpToken, lastID)
|
|
|
|
// Should get 482 ERR_CHANOPRIVSNEEDED.
|
|
if !findNumeric(msgs, "482") {
|
|
t.Fatalf(
|
|
"expected ERR_CHANOPRIVSNEEDED (482), "+
|
|
"got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
// Non-op tries +v.
|
|
_, lastID = tserver.pollMessages(nonOpToken, 0)
|
|
|
|
tserver.sendCommand(nonOpToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#noperm",
|
|
bodyKey: []string{"+v", "target3"},
|
|
})
|
|
|
|
msgs, _ = tserver.pollMessages(nonOpToken, lastID)
|
|
|
|
if !findNumeric(msgs, "482") {
|
|
t.Fatalf(
|
|
"expected ERR_CHANOPRIVSNEEDED (482) for +v, "+
|
|
"got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestModeratedChannelBlocksNonVoiced tests +m mode.
|
|
func TestModeratedChannelBlocksNonVoiced(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("modop")
|
|
regularToken := tserver.createSession("regular2")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#moderated",
|
|
})
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#moderated",
|
|
})
|
|
|
|
// Set +m.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#moderated",
|
|
bodyKey: []string{"+m"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(regularToken, 0)
|
|
|
|
// Regular user tries to send — should be blocked.
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#moderated",
|
|
bodyKey: []string{"blocked message"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(regularToken, lastID)
|
|
|
|
if !findNumeric(msgs, "404") {
|
|
t.Fatalf(
|
|
"expected ERR_CANNOTSENDTOCHAN (404) "+
|
|
"for +m, got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestModeratedChannelAllowsOp tests that operators can
|
|
// send in +m channels.
|
|
func TestModeratedChannelAllowsOp(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("modop2")
|
|
observerToken := tserver.createSession("observer2")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#modop",
|
|
})
|
|
tserver.sendCommand(observerToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#modop",
|
|
})
|
|
|
|
// Set +m.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#modop",
|
|
bodyKey: []string{"+m"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(observerToken, 0)
|
|
|
|
// Op sends — should work.
|
|
status, result := tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#modop",
|
|
bodyKey: []string{"op message"},
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(observerToken, lastID)
|
|
|
|
if !findMessage(msgs, privmsgCmd, "modop2") {
|
|
t.Fatalf(
|
|
"observer didn't receive op's message: %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestModeratedChannelAllowsVoiced tests that voiced
|
|
// users can send in +m channels.
|
|
func TestModeratedChannelAllowsVoiced(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("modop3")
|
|
voicedToken := tserver.createSession("modvoiced")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#modvoice",
|
|
})
|
|
tserver.sendCommand(voicedToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#modvoice",
|
|
})
|
|
|
|
// Set +m and +v on voiced user.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#modvoice",
|
|
bodyKey: []string{"+m"},
|
|
})
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#modvoice",
|
|
bodyKey: []string{"+v", "modvoiced"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(opToken, 0)
|
|
|
|
// Voiced user sends — should work.
|
|
status, result := tserver.sendCommand(
|
|
voicedToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#modvoice",
|
|
bodyKey: []string{"voiced message"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(opToken, lastID)
|
|
|
|
if !findMessage(msgs, privmsgCmd, "modvoiced") {
|
|
t.Fatalf(
|
|
"op didn't receive voiced user's message: %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestTopicLockDefaultOn verifies new channels have +t.
|
|
func TestTopicLockDefaultOn(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("topiclock")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#tldefault",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Query mode — should show +nt.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd, toKey: "#tldefault",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
modeMsg := findNumericWithParams(msgs, "324")
|
|
if modeMsg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_CHANNELMODEIS (324), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
params := getNumericParams(modeMsg)
|
|
if len(params) < 2 {
|
|
t.Fatalf(
|
|
"expected at least 2 params, got %v",
|
|
params,
|
|
)
|
|
}
|
|
|
|
if !strings.Contains(params[1], "t") {
|
|
t.Fatalf(
|
|
"expected +t in mode string, got %q",
|
|
params[1],
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestTopicLockEnforced verifies non-operators cannot
|
|
// change topic when +t is active.
|
|
func TestTopicLockEnforced(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("topicop")
|
|
regularToken := tserver.createSession("topicuser")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#tlock",
|
|
})
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#tlock",
|
|
})
|
|
|
|
// +t is on by default. Non-op tries to set topic.
|
|
_, lastID := tserver.pollMessages(regularToken, 0)
|
|
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: "TOPIC",
|
|
toKey: "#tlock",
|
|
bodyKey: []string{"unauthorized topic"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(regularToken, lastID)
|
|
|
|
if !findNumeric(msgs, "482") {
|
|
t.Fatalf(
|
|
"expected ERR_CHANOPRIVSNEEDED (482) "+
|
|
"for +t, got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestTopicLockOpCanChange verifies operators CAN change
|
|
// topic when +t is active.
|
|
func TestTopicLockOpCanChange(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("topicop2")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#tlock2",
|
|
})
|
|
|
|
// Op sets topic — should succeed (creator has +o).
|
|
status, result := tserver.sendCommand(
|
|
opToken, map[string]any{
|
|
commandKey: "TOPIC",
|
|
toKey: "#tlock2",
|
|
bodyKey: []string{"op topic"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["topic"] != "op topic" {
|
|
t.Fatalf(
|
|
"expected topic 'op topic', got %v",
|
|
result["topic"],
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestTopicLockDisabled verifies that -t allows anyone
|
|
// to change topic.
|
|
func TestTopicLockDisabled(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("tloff_op")
|
|
regularToken := tserver.createSession("tloff_user")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#tloff",
|
|
})
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#tloff",
|
|
})
|
|
|
|
// Disable +t.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#tloff",
|
|
bodyKey: []string{"-t"},
|
|
})
|
|
|
|
// Now regular user sets topic — should succeed.
|
|
status, result := tserver.sendCommand(
|
|
regularToken, map[string]any{
|
|
commandKey: "TOPIC",
|
|
toKey: "#tloff",
|
|
bodyKey: []string{"user topic"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["topic"] != "user topic" {
|
|
t.Fatalf(
|
|
"expected 'user topic', got %v",
|
|
result["topic"],
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestKickByOperator tests that an operator can kick
|
|
// a user.
|
|
func TestKickByOperator(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("kicker")
|
|
targetToken := tserver.createSession("kicked")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#kicktest",
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#kicktest",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(targetToken, 0)
|
|
|
|
// Kick the target.
|
|
status, _ := tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: kickCmd,
|
|
toKey: "#kicktest",
|
|
bodyKey: []string{"kicked", "misbehaving"},
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
// Target should receive KICK message.
|
|
msgs, _ := tserver.pollMessages(targetToken, lastID)
|
|
|
|
if !findMessage(msgs, kickCmd, "kicker") {
|
|
t.Fatalf(
|
|
"kicked user didn't receive KICK: %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
// Verify kicked user is no longer in channel:
|
|
// try to send — should fail.
|
|
_, lastID = tserver.pollMessages(targetToken, 0)
|
|
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#kicktest",
|
|
bodyKey: []string{"still here?"},
|
|
})
|
|
|
|
msgs, _ = tserver.pollMessages(targetToken, lastID)
|
|
|
|
if !findNumeric(msgs, "404") {
|
|
t.Fatalf(
|
|
"expected 404 after kick, got %v", msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestKickByNonOperator tests that non-operators cannot
|
|
// kick.
|
|
func TestKickByNonOperator(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("op_kick2")
|
|
nonOpToken := tserver.createSession("nonop_kick")
|
|
targetToken := tserver.createSession("target_kick")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#kickperm",
|
|
})
|
|
tserver.sendCommand(nonOpToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#kickperm",
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#kickperm",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(nonOpToken, 0)
|
|
|
|
// Non-op tries to kick.
|
|
tserver.sendCommand(nonOpToken, map[string]any{
|
|
commandKey: kickCmd,
|
|
toKey: "#kickperm",
|
|
bodyKey: []string{"target_kick", "nope"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(nonOpToken, lastID)
|
|
|
|
if !findNumeric(msgs, "482") {
|
|
t.Fatalf(
|
|
"expected ERR_CHANOPRIVSNEEDED (482), "+
|
|
"got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestKickTargetNotInChannel tests kicking a user not
|
|
// in the channel.
|
|
func TestKickTargetNotInChannel(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("op_kicknot")
|
|
_ = tserver.createSession("outsider")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#kicknotinchan",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(opToken, 0)
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: kickCmd,
|
|
toKey: "#kicknotinchan",
|
|
bodyKey: []string{"outsider", "bye"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(opToken, lastID)
|
|
|
|
if !findNumeric(msgs, "441") {
|
|
t.Fatalf(
|
|
"expected ERR_USERNOTINCHANNEL (441), "+
|
|
"got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestKickBroadcastToChannel verifies the KICK message
|
|
// is broadcast to all channel members.
|
|
func TestKickBroadcastToChannel(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("op_kb")
|
|
targetToken := tserver.createSession("target_kb")
|
|
observerToken := tserver.createSession("obs_kb")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#kickbc",
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#kickbc",
|
|
})
|
|
tserver.sendCommand(observerToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#kickbc",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(observerToken, 0)
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: kickCmd,
|
|
toKey: "#kickbc",
|
|
bodyKey: []string{"target_kb", "reason"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(observerToken, lastID)
|
|
|
|
if !findMessage(msgs, kickCmd, "op_kb") {
|
|
t.Fatalf(
|
|
"observer didn't receive KICK: %v", msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestNoticeNoAwayReply verifies NOTICE doesn't trigger
|
|
// RPL_AWAY.
|
|
func TestNoticeNoAwayReply(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
senderToken := tserver.createSession("noticesend")
|
|
awayToken := tserver.createSession("noticeaway")
|
|
|
|
// Set away status.
|
|
tserver.sendCommand(awayToken, map[string]any{
|
|
commandKey: "AWAY",
|
|
bodyKey: []string{"I am away"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(senderToken, 0)
|
|
|
|
// Send NOTICE — should NOT trigger RPL_AWAY.
|
|
tserver.sendCommand(senderToken, map[string]any{
|
|
commandKey: "NOTICE",
|
|
toKey: "noticeaway",
|
|
bodyKey: []string{"notice message"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(senderToken, lastID)
|
|
|
|
if findNumeric(msgs, "301") {
|
|
t.Fatalf(
|
|
"NOTICE should NOT trigger RPL_AWAY (301), "+
|
|
"got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestPrivmsgTriggersAway verifies PRIVMSG DOES trigger
|
|
// RPL_AWAY.
|
|
func TestPrivmsgTriggersAway(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
senderToken := tserver.createSession("msgsend")
|
|
awayToken := tserver.createSession("msgaway")
|
|
|
|
// Set away status.
|
|
tserver.sendCommand(awayToken, map[string]any{
|
|
commandKey: "AWAY",
|
|
bodyKey: []string{"I am away"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(senderToken, 0)
|
|
|
|
// Send PRIVMSG — should trigger RPL_AWAY.
|
|
tserver.sendCommand(senderToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "msgaway",
|
|
bodyKey: []string{"hello?"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(senderToken, lastID)
|
|
|
|
if !findNumeric(msgs, "301") {
|
|
t.Fatalf(
|
|
"PRIVMSG should trigger RPL_AWAY (301), "+
|
|
"got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestNoticeSkipsHashcash verifies NOTICE bypasses
|
|
// hashcash on +H channels.
|
|
func TestNoticeSkipsHashcash(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("hcnotice_op")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#hcnotice",
|
|
})
|
|
|
|
// Set hashcash requirement.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#hcnotice",
|
|
bodyKey: []string{"+H", "2"},
|
|
})
|
|
|
|
// Send NOTICE without hashcash — should succeed.
|
|
status, result := tserver.sendCommand(
|
|
opToken, map[string]any{
|
|
commandKey: "NOTICE",
|
|
toKey: "#hcnotice",
|
|
bodyKey: []string{"server notice"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["id"] == nil || result["id"] == "" {
|
|
t.Fatal("expected message id for NOTICE")
|
|
}
|
|
}
|
|
|
|
// TestModeratedNoticeBlocked verifies +m blocks NOTICE
|
|
// from non-voiced/non-op users too.
|
|
func TestModeratedNoticeBlocked(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("modnotop")
|
|
regularToken := tserver.createSession("modnotice")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#modnotice",
|
|
})
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#modnotice",
|
|
})
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#modnotice",
|
|
bodyKey: []string{"+m"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(regularToken, 0)
|
|
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: "NOTICE",
|
|
toKey: "#modnotice",
|
|
bodyKey: []string{"blocked notice"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(regularToken, lastID)
|
|
|
|
if !findNumeric(msgs, "404") {
|
|
t.Fatalf(
|
|
"expected 404 for NOTICE in +m, got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestNonOpCannotSetModerated verifies non-operators
|
|
// cannot set +m.
|
|
func TestNonOpCannotSetModerated(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("setmod_op")
|
|
regularToken := tserver.createSession("setmod_reg")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#modperm",
|
|
})
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#modperm",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(regularToken, 0)
|
|
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#modperm",
|
|
bodyKey: []string{"+m"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(regularToken, lastID)
|
|
|
|
if !findNumeric(msgs, "482") {
|
|
t.Fatalf(
|
|
"expected 482 for non-op +m, got %v", msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestISupportPrefix verifies PREFIX=(ov)@+ is in 005.
|
|
func TestISupportPrefix(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("isupport")
|
|
|
|
msgs, _ := tserver.pollMessages(token, 0)
|
|
|
|
isupportMsg := findNumericWithParams(msgs, "005")
|
|
if isupportMsg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_ISUPPORT (005), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
params := getNumericParams(isupportMsg)
|
|
|
|
found := false
|
|
|
|
for _, param := range params {
|
|
if param == "PREFIX=(ov)@+" {
|
|
found = true
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
t.Fatalf(
|
|
"expected PREFIX=(ov)@+ in ISUPPORT, "+
|
|
"got %v",
|
|
params,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestModeQueryShowsModerated verifies MODE query shows
|
|
// +m when set.
|
|
func TestModeQueryShowsModerated(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("mquery")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#mqtest",
|
|
})
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#mqtest",
|
|
bodyKey: []string{"+m"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd, toKey: "#mqtest",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
modeMsg := findNumericWithParams(msgs, "324")
|
|
if modeMsg == nil {
|
|
t.Fatal("expected 324")
|
|
}
|
|
|
|
params := getNumericParams(modeMsg)
|
|
|
|
if len(params) < 2 || !strings.Contains(params[1], "m") {
|
|
t.Fatalf(
|
|
"expected +m in mode string, got %v",
|
|
params,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestKickDefaultReason verifies KICK uses kicker's
|
|
// nick as default reason.
|
|
func TestKickDefaultReason(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("kickdefop")
|
|
targetToken := tserver.createSession("kickdeftg")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#kickdef",
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#kickdef",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(targetToken, 0)
|
|
|
|
// Kick with only nick, no reason.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: kickCmd,
|
|
toKey: "#kickdef",
|
|
bodyKey: []string{"kickdeftg"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(targetToken, lastID)
|
|
|
|
if !findMessage(msgs, kickCmd, "kickdefop") {
|
|
t.Fatalf(
|
|
"expected KICK message, got %v", msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// --- Tier 2 Handler Tests ---
|
|
|
|
const (
|
|
inviteCmd = "INVITE"
|
|
joinedStatus = "joined"
|
|
)
|
|
|
|
// TestBanAddRemoveList verifies +b add, list, and -b
|
|
// remove via MODE commands.
|
|
func TestBanAddRemoveList(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("banop")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#bans",
|
|
})
|
|
|
|
// Add a ban.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#bans",
|
|
bodyKey: []string{"+b", "*!*@evil.com"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(opToken, 0)
|
|
|
|
// List bans (+b with no argument).
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#bans",
|
|
bodyKey: []string{"+b"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(opToken, lastID)
|
|
|
|
// Should have RPL_BANLIST (367).
|
|
banMsg := findNumericWithParams(msgs, "367")
|
|
if banMsg == nil {
|
|
t.Fatalf("expected 367 RPL_BANLIST, got %v", msgs)
|
|
}
|
|
|
|
// Should have RPL_ENDOFBANLIST (368).
|
|
if !findNumeric(msgs, "368") {
|
|
t.Fatal("expected 368 RPL_ENDOFBANLIST")
|
|
}
|
|
|
|
// Remove the ban.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#bans",
|
|
bodyKey: []string{"-b", "*!*@evil.com"},
|
|
})
|
|
|
|
_, lastID = tserver.pollMessages(opToken, lastID)
|
|
|
|
// List again — should be empty (just end-of-list).
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#bans",
|
|
bodyKey: []string{"+b"},
|
|
})
|
|
|
|
msgs, _ = tserver.pollMessages(opToken, lastID)
|
|
banMsg = findNumericWithParams(msgs, "367")
|
|
if banMsg != nil {
|
|
t.Fatal("expected no 367 after ban removal")
|
|
}
|
|
|
|
if !findNumeric(msgs, "368") {
|
|
t.Fatal("expected 368 RPL_ENDOFBANLIST")
|
|
}
|
|
}
|
|
|
|
// TestBanBlocksJoin verifies that a banned user cannot
|
|
// join a channel.
|
|
func TestBanBlocksJoin(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("banop2")
|
|
userToken := tserver.createSession("banned2")
|
|
|
|
// Op creates channel and sets a ban.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#banjoin",
|
|
})
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#banjoin",
|
|
bodyKey: []string{"+b", "banned2!*@*"},
|
|
})
|
|
|
|
// Banned user tries to join.
|
|
_, lastID := tserver.pollMessages(userToken, 0)
|
|
tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#banjoin",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(userToken, lastID)
|
|
|
|
// Should get ERR_BANNEDFROMCHAN (474).
|
|
if !findNumeric(msgs, "474") {
|
|
t.Fatalf("expected 474 ERR_BANNEDFROMCHAN, got %v", msgs)
|
|
}
|
|
}
|
|
|
|
// TestBanBlocksPrivmsg verifies that a banned user who
|
|
// is already in a channel cannot send messages.
|
|
func TestBanBlocksPrivmsg(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("banmsgop")
|
|
userToken := tserver.createSession("banmsgusr")
|
|
|
|
// Both join.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#banmsg",
|
|
})
|
|
tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#banmsg",
|
|
})
|
|
|
|
// Op bans the user.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#banmsg",
|
|
bodyKey: []string{"+b", "banmsgusr!*@*"},
|
|
})
|
|
|
|
// User tries to send a message.
|
|
_, lastID := tserver.pollMessages(userToken, 0)
|
|
tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#banmsg",
|
|
bodyKey: []string{"hello"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(userToken, lastID)
|
|
|
|
// Should get ERR_CANNOTSENDTOCHAN (404).
|
|
if !findNumeric(msgs, "404") {
|
|
t.Fatalf("expected 404 ERR_CANNOTSENDTOCHAN, got %v", msgs)
|
|
}
|
|
}
|
|
|
|
// TestInviteOnlyJoin verifies +i behavior: join rejected
|
|
// without invite, accepted with invite.
|
|
func TestInviteOnlyJoin(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("invop")
|
|
userToken := tserver.createSession("invusr")
|
|
|
|
// Op creates channel and sets +i.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#invonly",
|
|
})
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#invonly",
|
|
bodyKey: []string{"+i"},
|
|
})
|
|
|
|
// User tries to join without invite.
|
|
_, lastID := tserver.pollMessages(userToken, 0)
|
|
tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#invonly",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(userToken, lastID)
|
|
|
|
if !findNumeric(msgs, "473") {
|
|
t.Fatalf(
|
|
"expected 473 ERR_INVITEONLYCHAN, got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
// Op invites user.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: inviteCmd,
|
|
bodyKey: []string{"invusr", "#invonly"},
|
|
})
|
|
|
|
// User tries again — should succeed with invite.
|
|
_, result := tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#invonly",
|
|
})
|
|
|
|
if result[statusKey] != joinedStatus {
|
|
t.Fatalf(
|
|
"expected join to succeed with invite, got %v",
|
|
result,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestSecretChannelHiddenFromList verifies +s hides a
|
|
// channel from LIST for non-members.
|
|
func TestSecretChannelHiddenFromList(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("secop")
|
|
outsiderToken := tserver.createSession("secout")
|
|
|
|
// Op creates secret channel.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#secret",
|
|
})
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#secret",
|
|
bodyKey: []string{"+s"},
|
|
})
|
|
|
|
// Outsider does LIST.
|
|
_, lastID := tserver.pollMessages(outsiderToken, 0)
|
|
tserver.sendCommand(outsiderToken, map[string]any{
|
|
commandKey: "LIST",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(outsiderToken, lastID)
|
|
|
|
// Should NOT see #secret in any 322 (RPL_LIST).
|
|
for _, msg := range msgs {
|
|
code, ok := msg["code"].(float64)
|
|
if !ok || int(code) != 322 {
|
|
continue
|
|
}
|
|
|
|
params := getNumericParams(msg)
|
|
for _, p := range params {
|
|
if p == "#secret" {
|
|
t.Fatal("outsider should not see #secret in LIST")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Member does LIST — should see it.
|
|
_, lastID = tserver.pollMessages(opToken, 0)
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: "LIST",
|
|
})
|
|
|
|
msgs, _ = tserver.pollMessages(opToken, lastID)
|
|
|
|
found := false
|
|
|
|
for _, msg := range msgs {
|
|
code, ok := msg["code"].(float64)
|
|
if !ok || int(code) != 322 {
|
|
continue
|
|
}
|
|
|
|
params := getNumericParams(msg)
|
|
for _, p := range params {
|
|
if p == "#secret" {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
t.Fatal("member should see #secret in LIST")
|
|
}
|
|
}
|
|
|
|
// TestChannelKeyJoin verifies +k behavior: wrong/missing
|
|
// key is rejected, correct key allows join.
|
|
func TestChannelKeyJoin(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("keyop")
|
|
userToken := tserver.createSession("keyusr")
|
|
|
|
// Op creates keyed channel.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#keyed",
|
|
})
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#keyed",
|
|
bodyKey: []string{"+k", "mykey"},
|
|
})
|
|
|
|
// User tries without key.
|
|
_, lastID := tserver.pollMessages(userToken, 0)
|
|
tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#keyed",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(userToken, lastID)
|
|
|
|
if !findNumeric(msgs, "475") {
|
|
t.Fatalf(
|
|
"expected 475 ERR_BADCHANNELKEY, got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
// User tries with wrong key.
|
|
tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: joinCmd,
|
|
toKey: "#keyed",
|
|
bodyKey: []string{"wrongkey"},
|
|
})
|
|
|
|
msgs, _ = tserver.pollMessages(userToken, lastID)
|
|
if !findNumeric(msgs, "475") {
|
|
t.Fatalf(
|
|
"expected 475 ERR_BADCHANNELKEY for wrong key, got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
// User tries with correct key.
|
|
_, result := tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: joinCmd,
|
|
toKey: "#keyed",
|
|
bodyKey: []string{"mykey"},
|
|
})
|
|
|
|
if result[statusKey] != joinedStatus {
|
|
t.Fatalf(
|
|
"expected join to succeed with correct key, got %v",
|
|
result,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestUserLimitEnforcement verifies +l behavior: blocks
|
|
// join when at capacity.
|
|
func TestUserLimitEnforcement(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("limop")
|
|
user1Token := tserver.createSession("limusr1")
|
|
user2Token := tserver.createSession("limusr2")
|
|
|
|
// Op creates channel with limit 2.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#limited",
|
|
})
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#limited",
|
|
bodyKey: []string{"+l", "2"},
|
|
})
|
|
|
|
// User1 joins — should succeed (2 members now: op + user1).
|
|
_, result := tserver.sendCommand(user1Token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#limited",
|
|
})
|
|
if result[statusKey] != joinedStatus {
|
|
t.Fatalf("user1 should join, got %v", result)
|
|
}
|
|
|
|
// User2 tries to join — should fail (at limit: 2/2).
|
|
_, lastID := tserver.pollMessages(user2Token, 0)
|
|
tserver.sendCommand(user2Token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#limited",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(user2Token, lastID)
|
|
|
|
if !findNumeric(msgs, "471") {
|
|
t.Fatalf(
|
|
"expected 471 ERR_CHANNELISFULL, got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestModeStringIncludesNewModes verifies that querying
|
|
// channel mode returns the new modes (+i, +s, +k, +l).
|
|
func TestModeStringIncludesNewModes(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("modestrop")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#modestr",
|
|
})
|
|
|
|
// Set all tier 2 modes.
|
|
for _, modeChange := range [][]string{
|
|
{"+i"}, {"+s"}, {"+k", "pw"}, {"+l", "50"},
|
|
} {
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#modestr",
|
|
bodyKey: modeChange,
|
|
})
|
|
}
|
|
|
|
_, lastID := tserver.pollMessages(opToken, 0)
|
|
|
|
// Query mode.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd, toKey: "#modestr",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(opToken, lastID)
|
|
modeMsg := findNumericWithParams(msgs, "324")
|
|
|
|
if modeMsg == nil {
|
|
t.Fatal("expected 324 RPL_CHANNELMODEIS")
|
|
}
|
|
|
|
params := getNumericParams(modeMsg)
|
|
if len(params) < 2 {
|
|
t.Fatalf("too few params in 324: %v", params)
|
|
}
|
|
|
|
modeString := params[1]
|
|
|
|
for _, c := range []string{"i", "s", "k", "l"} {
|
|
if !strings.Contains(modeString, c) {
|
|
t.Fatalf(
|
|
"mode string %q missing %q",
|
|
modeString, c,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestISUPPORT verifies the 005 numeric includes the
|
|
// updated CHANMODES string.
|
|
func TestISUPPORT(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("isupport")
|
|
|
|
msgs, _ := tserver.pollMessages(token, 0)
|
|
|
|
isupp := findNumericWithParams(msgs, "005")
|
|
if isupp == nil {
|
|
t.Fatal("expected 005 RPL_ISUPPORT")
|
|
}
|
|
|
|
body, _ := isupp["body"].(string)
|
|
params := getNumericParams(isupp)
|
|
|
|
combined := body + " " + strings.Join(params, " ")
|
|
|
|
if !strings.Contains(combined, "CHANMODES=b,k,Hl,imnst") {
|
|
t.Fatalf(
|
|
"ISUPPORT missing updated CHANMODES, got body=%q params=%v",
|
|
body, params,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestNonOpCannotSetModes verifies non-operators
|
|
// cannot set +i, +s, +k, +l, +b.
|
|
func TestNonOpCannotSetModes(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("modeopx")
|
|
userToken := tserver.createSession("modeusrx")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#noperm",
|
|
})
|
|
tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#noperm",
|
|
})
|
|
|
|
modes := [][]string{
|
|
{"+i"}, {"+s"}, {"+k", "key"}, {"+l", "10"},
|
|
{"+b", "bad!*@*"},
|
|
}
|
|
|
|
for _, modeChange := range modes {
|
|
_, lastID := tserver.pollMessages(userToken, 0)
|
|
tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: "#noperm",
|
|
bodyKey: modeChange,
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(userToken, lastID)
|
|
|
|
// Should get 482 ERR_CHANOPRIVSNEEDED.
|
|
if !findNumeric(msgs, "482") {
|
|
t.Fatalf(
|
|
"expected 482 for %v, got %v",
|
|
modeChange, msgs,
|
|
)
|
|
}
|
|
}
|
|
}
|