check / check (push) Waiting to run
A session request refused with 400 or 409, or a +H channel message refused for membership, a ban or +m, used to spend its stamp, so a retry with the same stamp was refused as already used. hashcash.Validator.Validate is split into Check, which spends nothing, and Claim, which records the stamp; Release undoes a claim. Session creation checks the stamp first, claims it just before CreateSession and releases it if CreateSession fails. SendChannelMessage checks the stamp after the membership, ban and +m checks. Model: opus-5-5
6176 lines
132 KiB
Go
6176 lines
132 KiB
Go
// Tests use a global viper instance for configuration,
|
|
// making parallel execution unsafe.
|
|
//
|
|
//nolint:paralleltest
|
|
package handlers_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/fx"
|
|
"go.uber.org/fx/fxtest"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"modernc.org/sqlite"
|
|
"sneak.berlin/go/neoirc/internal/broker"
|
|
api "sneak.berlin/go/neoirc/internal/cli/api"
|
|
"sneak.berlin/go/neoirc/internal/config"
|
|
"sneak.berlin/go/neoirc/internal/db"
|
|
"sneak.berlin/go/neoirc/internal/globals"
|
|
"sneak.berlin/go/neoirc/internal/handlers"
|
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
|
"sneak.berlin/go/neoirc/internal/healthcheck"
|
|
"sneak.berlin/go/neoirc/internal/logger"
|
|
"sneak.berlin/go/neoirc/internal/middleware"
|
|
"sneak.berlin/go/neoirc/internal/server"
|
|
"sneak.berlin/go/neoirc/internal/service"
|
|
"sneak.berlin/go/neoirc/internal/stats"
|
|
"sneak.berlin/go/neoirc/pkg/irc"
|
|
)
|
|
|
|
// templateDBURL names the in-memory database TestMain migrates once
|
|
// and every test copies; it lasts while TestMain holds it open.
|
|
const templateDBURL = "file:template?mode=memory&cache=shared"
|
|
|
|
func TestMain(m *testing.M) {
|
|
db.SetBcryptCost(bcrypt.MinCost)
|
|
|
|
template, err := sql.Open("sqlite", templateDBURL)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
err = db.NewTestDatabaseFromConn(template).
|
|
RunMigrations(context.Background())
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
code := m.Run()
|
|
|
|
_ = template.Close()
|
|
|
|
os.Exit(code)
|
|
}
|
|
|
|
// copyTemplateDB creates the in-memory database dbURL as a copy of
|
|
// the migrated template, so a test does not migrate the schema
|
|
// again. The copy lasts until the test ends.
|
|
func copyTemplateDB(t *testing.T, dbURL string) {
|
|
t.Helper()
|
|
|
|
database, err := sql.Open("sqlite", dbURL)
|
|
if err != nil {
|
|
t.Fatalf("open test db: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
|
|
conn, err := database.Conn(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("connect test db: %v", err)
|
|
}
|
|
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
err = conn.Raw(func(driverConn any) error {
|
|
restorer, ok := driverConn.(interface {
|
|
NewRestore(srcURI string) (*sqlite.Backup, error)
|
|
})
|
|
if !ok {
|
|
return errors.ErrUnsupported
|
|
}
|
|
|
|
restore, restoreErr := restorer.NewRestore(templateDBURL)
|
|
if restoreErr != nil {
|
|
return fmt.Errorf("start restore: %w", restoreErr)
|
|
}
|
|
|
|
_, restoreErr = restore.Step(-1)
|
|
if restoreErr != nil {
|
|
_ = restore.Finish()
|
|
|
|
return fmt.Errorf("restore: %w", restoreErr)
|
|
}
|
|
|
|
return restore.Finish()
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("copy template db: %v", err)
|
|
}
|
|
}
|
|
|
|
const (
|
|
commandKey = "command"
|
|
bodyKey = "body"
|
|
toKey = "to"
|
|
statusKey = "status"
|
|
nickKey = "nick"
|
|
passwordKey = "password"
|
|
testPassword = "password123"
|
|
privmsgCmd = "PRIVMSG"
|
|
joinCmd = "JOIN"
|
|
apiMessages = "/api/v1/messages"
|
|
apiLogout = "/api/v1/logout"
|
|
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
|
|
broker *broker.Broker
|
|
database *db.Database
|
|
}
|
|
|
|
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()
|
|
|
|
// A random name, so that no other test can share it, not
|
|
// even one that has finished but still holds its database
|
|
// open.
|
|
dbURL := fmt.Sprintf(
|
|
"file:%s?mode=memory&cache=shared",
|
|
rand.Text(),
|
|
)
|
|
copyTemplateDB(t, dbURL)
|
|
|
|
var (
|
|
srv *server.Server
|
|
brk *broker.Broker
|
|
database *db.Database
|
|
)
|
|
|
|
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
|
|
// One line: each line is a database write per new session.
|
|
cfg.MOTD = "neoirc test server"
|
|
cfg.HashcashBits = hashcashBits
|
|
cfg.OperName = operName
|
|
cfg.OperPassword = operPassword
|
|
|
|
return cfg, nil
|
|
},
|
|
newTestDB,
|
|
broker.New,
|
|
stats.New,
|
|
newTestHealthcheck,
|
|
newTestMiddleware,
|
|
newTestHandlers,
|
|
newTestServerFx,
|
|
),
|
|
fx.Populate(&srv, &brk, &database),
|
|
)
|
|
|
|
app.RequireStart()
|
|
|
|
httpSrv := httptest.NewServer(srv)
|
|
|
|
t.Cleanup(func() {
|
|
httpSrv.Close()
|
|
app.RequireStop()
|
|
})
|
|
|
|
return &testServer{
|
|
httpServer: httpSrv,
|
|
t: t,
|
|
fxApp: app,
|
|
broker: brk,
|
|
database: database,
|
|
}
|
|
}
|
|
|
|
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,
|
|
brk *broker.Broker,
|
|
) (*handlers.Handlers, error) {
|
|
svc := service.New(service.Params{ //nolint:exhaustruct
|
|
Logger: log,
|
|
Config: cfg,
|
|
Database: database,
|
|
Broker: brk,
|
|
Globals: globs,
|
|
})
|
|
|
|
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
|
Logger: log,
|
|
Globals: globs,
|
|
Config: cfg,
|
|
Database: database,
|
|
Healthcheck: hcheck,
|
|
Stats: tracker,
|
|
Broker: brk,
|
|
Service: svc,
|
|
})
|
|
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,
|
|
shutdowner fx.Shutdowner,
|
|
) (*server.Server, error) {
|
|
srv, err := server.New(lifecycle, server.Params{ //nolint:exhaustruct
|
|
Logger: log,
|
|
Globals: globs,
|
|
Config: cfg,
|
|
Middleware: mware,
|
|
Handlers: hdlr,
|
|
Shutdowner: shutdowner,
|
|
})
|
|
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 != "" {
|
|
// A request cookie carries only its name and value, so
|
|
// gosec's G124 attribute check does not apply.
|
|
request.AddCookie(&http.Cookie{ //nolint:exhaustruct,gosec // only name+value
|
|
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{nickKey: 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{nickKey: 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)
|
|
}
|
|
|
|
// Nicks are case-sensitive: Alice is not alice.
|
|
tserver.createSession("Alice")
|
|
}
|
|
|
|
// TestEachTestServerHasItsOwnDatabase checks that starting a
|
|
// second test server leaves the first one's database alone and
|
|
// starts on an empty one of its own.
|
|
func TestEachTestServerHasItsOwnDatabase(t *testing.T) {
|
|
first := newTestServer(t)
|
|
first.createSession("samenick")
|
|
|
|
second := newTestServer(t)
|
|
|
|
resp := postSession(t, first, "samenick")
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf(
|
|
"first server: expected 409, got %d",
|
|
resp.StatusCode,
|
|
)
|
|
}
|
|
|
|
second.createSession("samenick")
|
|
}
|
|
|
|
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[nickKey] != "authtest" {
|
|
t.Fatalf(
|
|
"expected nick authtest, got %v",
|
|
result[nickKey],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestJoinChannel(t *testing.T) {
|
|
const channel = "#test"
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("joiner")
|
|
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["channel"] != channel {
|
|
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) {
|
|
const channel = "#test"
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("parter")
|
|
|
|
tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
},
|
|
)
|
|
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: "PART", toKey: channel,
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["channel"] != channel {
|
|
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) {
|
|
const channel = "#test"
|
|
|
|
tserver := newTestServer(t)
|
|
aliceToken := tserver.createSession("alice_msg")
|
|
bobToken := tserver.createSession("bob_msg")
|
|
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(bobToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
_, _ = tserver.pollMessages(aliceToken, 0)
|
|
_, bobLastID := tserver.pollMessages(bobToken, 0)
|
|
|
|
status, result := tserver.sendCommand(
|
|
aliceToken,
|
|
map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: channel,
|
|
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) {
|
|
const channel = "#test"
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("nobody")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: privmsgCmd, toKey: channel,
|
|
})
|
|
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: irc.CmdNick,
|
|
bodyKey: []string{"newnick"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result[nickKey] != "newnick" {
|
|
t.Fatalf(
|
|
"expected newnick, got %v", result[nickKey],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestNickSameAsCurrent(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("same_nick")
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: irc.CmdNick,
|
|
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: irc.CmdNick,
|
|
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: irc.CmdNick,
|
|
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: irc.CmdNick},
|
|
)
|
|
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) {
|
|
const channel = "#topictest"
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("topic_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: irc.CmdTopic,
|
|
toKey: channel,
|
|
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: irc.CmdTopic,
|
|
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) {
|
|
const channel = "#topictest"
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("topicnobody")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: irc.CmdTopic, toKey: channel,
|
|
})
|
|
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: irc.CmdTopic,
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
|
|
// quitRaceTrials is how many times checkQuitRace repeats
|
|
// its race.
|
|
const quitRaceTrials = 5
|
|
|
|
// TestConcurrentLogoutsSendOneQuit checks that when both
|
|
// clients of a session log out at once, the session's QUIT
|
|
// is sent once.
|
|
func TestConcurrentLogoutsSendOneQuit(t *testing.T) {
|
|
checkQuitRace(t, func(tserver *testServer, tokens []string) {
|
|
tserver.postConcurrently(apiLogout, nil, tokens...)
|
|
})
|
|
}
|
|
|
|
// TestConcurrentQuitsSendOneQuit checks that when both
|
|
// clients of a session send QUIT at once, the session's
|
|
// QUIT is sent once.
|
|
func TestConcurrentQuitsSendOneQuit(t *testing.T) {
|
|
quit, err := json.Marshal(map[string]any{commandKey: irc.CmdQuit})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
checkQuitRace(t, func(tserver *testServer, tokens []string) {
|
|
tserver.postConcurrently(apiMessages, quit, tokens...)
|
|
})
|
|
}
|
|
|
|
// checkQuitRace gives a session a password and two
|
|
// clients, puts it on a channel with a second session, and
|
|
// calls leave with the tokens of the two clients. It checks
|
|
// that the second session then holds one QUIT from the
|
|
// first, and that the first session is deleted: its tokens
|
|
// are refused and its nick is free. It does all this
|
|
// quitRaceTrials times, each on a new server, because each
|
|
// trial logs in and logins are rate limited.
|
|
func checkQuitRace(
|
|
t *testing.T,
|
|
leave func(tserver *testServer, tokens []string),
|
|
) {
|
|
t.Helper()
|
|
|
|
const nick = "racingquitter"
|
|
|
|
for trial := range quitRaceTrials {
|
|
tserver := newTestServer(t)
|
|
firstToken := tserver.createSession(nick)
|
|
observerToken := tserver.createSession("raceobserver")
|
|
|
|
tserver.sendCommand(firstToken, map[string]any{
|
|
commandKey: irc.CmdPass, bodyKey: []string{testPassword},
|
|
})
|
|
|
|
tokens := []string{firstToken, tserver.login(nick)}
|
|
|
|
for _, token := range []string{firstToken, observerToken} {
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#quitrace",
|
|
})
|
|
}
|
|
|
|
_, lastID := tserver.pollMessages(observerToken, 0)
|
|
|
|
leave(tserver, tokens)
|
|
|
|
msgs, _ := tserver.pollMessages(observerToken, lastID)
|
|
|
|
quits := countQuits(msgs, nick)
|
|
if quits != 1 {
|
|
t.Errorf("trial %d: %d QUITs, want 1: %v", trial, quits, msgs)
|
|
}
|
|
|
|
for _, token := range tokens {
|
|
status, _ := tserver.getState(token)
|
|
if status != http.StatusUnauthorized {
|
|
t.Errorf("trial %d: state got %d, want 401", trial, status)
|
|
}
|
|
}
|
|
|
|
// createSession fails the test if the nick is taken.
|
|
tserver.createSession(nick)
|
|
}
|
|
}
|
|
|
|
// TestLogoutOfOneClientKeepsSession checks that when one of
|
|
// a session's two clients logs out, the other client's
|
|
// token still works, the session stays on its channel and
|
|
// its channel peer gets no QUIT, and that the other
|
|
// client's logout then sends one QUIT.
|
|
func TestLogoutOfOneClientKeepsSession(t *testing.T) {
|
|
const (
|
|
nick = "twoclients"
|
|
channel = "#logouts"
|
|
)
|
|
|
|
tserver := newTestServer(t)
|
|
firstToken := tserver.createSession(nick)
|
|
observerToken := tserver.createSession("logoutobserver")
|
|
|
|
tserver.sendCommand(firstToken, map[string]any{
|
|
commandKey: irc.CmdPass, bodyKey: []string{testPassword},
|
|
})
|
|
|
|
secondToken := tserver.login(nick)
|
|
|
|
for _, token := range []string{firstToken, observerToken} {
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
}
|
|
|
|
_, lastID := tserver.pollMessages(observerToken, 0)
|
|
|
|
tserver.logout(firstToken)
|
|
|
|
status, _ := tserver.getState(firstToken)
|
|
if status != http.StatusUnauthorized {
|
|
t.Errorf("logged-out client's state: got %d, want 401", status)
|
|
}
|
|
|
|
status, state := tserver.getState(secondToken)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("other client's state: got %d, want 200", status)
|
|
}
|
|
|
|
names := channelNames(state)
|
|
if !slices.Equal(names, []string{channel}) {
|
|
t.Errorf("session's channels: got %v, want [%s]", names, channel)
|
|
}
|
|
|
|
msgs, lastID := tserver.pollMessages(observerToken, lastID)
|
|
|
|
quits := countQuits(msgs, nick)
|
|
if quits != 0 {
|
|
t.Errorf("after the first logout: %d QUITs, want 0: %v", quits, msgs)
|
|
}
|
|
|
|
tserver.logout(secondToken)
|
|
|
|
msgs, _ = tserver.pollMessages(observerToken, lastID)
|
|
|
|
quits = countQuits(msgs, nick)
|
|
if quits != 1 {
|
|
t.Errorf("after the second logout: %d QUITs, want 1: %v", quits, msgs)
|
|
}
|
|
}
|
|
|
|
// TestFailedLogoutChangesNothing checks that a logout whose
|
|
// session cannot be deleted answers 500 and changes
|
|
// nothing: the client's token still works, the session is
|
|
// still on its channel and its channel peer has no QUIT.
|
|
func TestFailedLogoutChangesNothing(t *testing.T) {
|
|
const (
|
|
nick = "stuckleaver"
|
|
channel = "#stuck"
|
|
)
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession(nick)
|
|
observerToken := tserver.createSession("stuckobserver")
|
|
|
|
for _, joiner := range []string{token, observerToken} {
|
|
tserver.sendCommand(joiner, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
}
|
|
|
|
_, lastID := tserver.pollMessages(observerToken, 0)
|
|
|
|
_, err := tserver.database.GetDB().ExecContext(t.Context(),
|
|
`CREATE TRIGGER keep_sessions
|
|
BEFORE DELETE ON sessions
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'sessions are kept');
|
|
END`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resp, err := doRequestAuth(
|
|
t, http.MethodPost, tserver.url(apiLogout), token, nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("logout: %v", err)
|
|
}
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Errorf("logout: got %d, want 500", resp.StatusCode)
|
|
}
|
|
|
|
status, state := tserver.getState(token)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("state after the failed logout: got %d, want 200", status)
|
|
}
|
|
|
|
names := channelNames(state)
|
|
if !slices.Equal(names, []string{channel}) {
|
|
t.Errorf("session's channels: got %v, want [%s]", names, channel)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(observerToken, lastID)
|
|
|
|
quits := countQuits(msgs, nick)
|
|
if quits != 0 {
|
|
t.Errorf("%d QUITs, want 0: %v", quits, msgs)
|
|
}
|
|
}
|
|
|
|
const (
|
|
// quitWakePollTimeout is the timeout, in seconds, of the
|
|
// long poll in checkQuitWakesPoll.
|
|
quitWakePollTimeout = 10
|
|
|
|
// quitWakeDeadline is how long checkQuitWakesPoll waits
|
|
// for that long poll to start waiting, and then to
|
|
// return once the session has left.
|
|
quitWakeDeadline = 5 * time.Second
|
|
|
|
// pollWaitStep is how often checkQuitWakesPoll checks
|
|
// whether the long poll is waiting yet.
|
|
pollWaitStep = 10 * time.Millisecond
|
|
)
|
|
|
|
// TestQuitWakesPeerLongPoll checks that a session's QUIT
|
|
// wakes the long poll of a user who shares a channel with
|
|
// it.
|
|
func TestQuitWakesPeerLongPoll(t *testing.T) {
|
|
const reason = "gone fishing"
|
|
|
|
checkQuitWakesPoll(t, reason, func(tserver *testServer, token string) {
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: irc.CmdQuit, bodyKey: []string{reason},
|
|
})
|
|
})
|
|
}
|
|
|
|
// TestLastLogoutWakesPeerLongPoll checks that the logout of
|
|
// a session's last client wakes the long poll of a user who
|
|
// shares a channel with it.
|
|
func TestLastLogoutWakesPeerLongPoll(t *testing.T) {
|
|
checkQuitWakesPoll(t, "Connection closed", func(tserver *testServer, token string) {
|
|
tserver.logout(token)
|
|
})
|
|
}
|
|
|
|
// checkQuitWakesPoll puts a session and a peer on a
|
|
// channel, and calls leave with the session's token while
|
|
// the peer long-polls with a timeout of quitWakePollTimeout
|
|
// seconds. It checks that the poll returns within
|
|
// quitWakeDeadline with the session's QUIT, whose body is
|
|
// reason.
|
|
func checkQuitWakesPoll(
|
|
t *testing.T,
|
|
reason string,
|
|
leave func(tserver *testServer, token string),
|
|
) {
|
|
t.Helper()
|
|
|
|
const (
|
|
nick = "wakequitter"
|
|
peerNick = "wakepeer"
|
|
)
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession(nick)
|
|
peerToken := tserver.createSession(peerNick)
|
|
|
|
for _, joiner := range []string{token, peerToken} {
|
|
tserver.sendCommand(joiner, map[string]any{
|
|
commandKey: joinCmd, toKey: "#wake",
|
|
})
|
|
}
|
|
|
|
_, lastID := tserver.pollMessages(peerToken, 0)
|
|
|
|
peerID, err := tserver.database.GetSessionByNick(t.Context(), peerNick)
|
|
if err != nil {
|
|
t.Fatalf("get session of %s: %v", peerNick, err)
|
|
}
|
|
|
|
var (
|
|
msgs []map[string]any
|
|
pollErr error
|
|
)
|
|
|
|
polled := make(chan struct{})
|
|
|
|
go func() {
|
|
defer close(polled)
|
|
|
|
msgs, pollErr = tserver.longPoll(peerToken, lastID, quitWakePollTimeout)
|
|
}()
|
|
|
|
deadline := time.Now().Add(quitWakeDeadline)
|
|
|
|
for tserver.broker.ListenerCount(peerID) == 0 {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the long poll did not start waiting")
|
|
}
|
|
|
|
time.Sleep(pollWaitStep)
|
|
}
|
|
|
|
leave(tserver, token)
|
|
|
|
select {
|
|
case <-polled:
|
|
case <-time.After(quitWakeDeadline):
|
|
t.Fatalf("the long poll did not return within %v", quitWakeDeadline)
|
|
}
|
|
|
|
if pollErr != nil {
|
|
t.Fatalf("long poll: %v", pollErr)
|
|
}
|
|
|
|
if len(msgs) != 1 || countQuits(msgs, nick) != 1 {
|
|
t.Fatalf("long poll: got %v, want one QUIT from %s", msgs, nick)
|
|
}
|
|
|
|
lines, _ := msgs[0][bodyKey].([]any)
|
|
if len(lines) != 1 || lines[0] != reason {
|
|
t.Errorf("QUIT body: got %v, want [%s]", lines, reason)
|
|
}
|
|
}
|
|
|
|
// channelNames returns the names of the channels in state,
|
|
// a response to GET /api/v1/state.
|
|
func channelNames(state map[string]any) []string {
|
|
channels, _ := state["channels"].([]any)
|
|
names := make([]string, 0, len(channels))
|
|
|
|
for _, entry := range channels {
|
|
info, _ := entry.(map[string]any)
|
|
name, _ := info["name"].(string)
|
|
names = append(names, name)
|
|
}
|
|
|
|
return names
|
|
}
|
|
|
|
// countQuits returns how many of msgs are a QUIT from nick.
|
|
func countQuits(msgs []map[string]any, nick string) int {
|
|
quits := 0
|
|
|
|
for _, msg := range msgs {
|
|
if msg[commandKey] == irc.CmdQuit && msg["from"] == nick {
|
|
quits++
|
|
}
|
|
}
|
|
|
|
return quits
|
|
}
|
|
|
|
// logout logs out the client token and fails the test
|
|
// unless the server answers 200.
|
|
func (tserver *testServer) logout(token string) {
|
|
tserver.t.Helper()
|
|
|
|
resp, err := doRequestAuth(
|
|
tserver.t, http.MethodPost,
|
|
tserver.url(apiLogout), token, nil,
|
|
)
|
|
if err != nil {
|
|
tserver.t.Fatalf("logout: %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
tserver.t.Fatalf("logout: status %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// longPoll returns the messages after afterID that the
|
|
// client token has been sent, waiting up to timeout seconds
|
|
// for one if there are none yet. It returns errors instead
|
|
// of failing the test, so a goroutine can call it.
|
|
func (tserver *testServer) longPoll(
|
|
token string, afterID int64, timeout int,
|
|
) ([]map[string]any, error) {
|
|
resp, err := doRequestAuth(
|
|
tserver.t, http.MethodGet,
|
|
tserver.url(fmt.Sprintf(
|
|
"%s?timeout=%d&after=%d", apiMessages, timeout, afterID,
|
|
)),
|
|
token, nil,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
var result struct {
|
|
Messages []map[string]any `json:"messages"`
|
|
}
|
|
|
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode poll: %w", err)
|
|
}
|
|
|
|
return result.Messages, nil
|
|
}
|
|
|
|
// login logs in to the session nick with testPassword and
|
|
// returns the new client's token.
|
|
func (tserver *testServer) login(nick string) string {
|
|
tserver.t.Helper()
|
|
|
|
body, err := json.Marshal(map[string]string{
|
|
nickKey: nick, passwordKey: testPassword,
|
|
})
|
|
if err != nil {
|
|
tserver.t.Fatalf("marshal login: %v", err)
|
|
}
|
|
|
|
resp, err := doRequest(
|
|
tserver.t, http.MethodPost,
|
|
tserver.url("/api/v1/login"), bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
tserver.t.Fatalf("login: %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
tserver.t.Fatalf("login: status %d", resp.StatusCode)
|
|
}
|
|
|
|
for _, cookie := range resp.Cookies() {
|
|
if cookie.Name == authCookieName {
|
|
return cookie.Value
|
|
}
|
|
}
|
|
|
|
tserver.t.Fatal("no auth cookie in login response")
|
|
|
|
return ""
|
|
}
|
|
|
|
// postConcurrently POSTs body to path once with each of
|
|
// tokens, all at once.
|
|
func (tserver *testServer) postConcurrently(
|
|
path string, body []byte, tokens ...string,
|
|
) {
|
|
tserver.t.Helper()
|
|
|
|
start := make(chan struct{})
|
|
|
|
var waitGroup sync.WaitGroup
|
|
|
|
for _, token := range tokens {
|
|
waitGroup.Add(1)
|
|
|
|
go func() {
|
|
defer waitGroup.Done()
|
|
|
|
<-start
|
|
|
|
resp, err := doRequestAuth(
|
|
tserver.t, http.MethodPost, tserver.url(path),
|
|
token, bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
tserver.t.Error(err)
|
|
|
|
return
|
|
}
|
|
|
|
_ = resp.Body.Close()
|
|
}()
|
|
}
|
|
|
|
close(start)
|
|
waitGroup.Wait()
|
|
}
|
|
|
|
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) {
|
|
const channel = "#secret"
|
|
|
|
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: channel,
|
|
})
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: channel,
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
|
|
// getHistory fetches one page of target's history and
|
|
// returns the status and, on 200, the messages on it.
|
|
func (tserver *testServer) getHistory(
|
|
token, target, before string,
|
|
limit int,
|
|
) (int, []db.IRCMessage) {
|
|
tserver.t.Helper()
|
|
|
|
query := url.Values{}
|
|
query.Set("target", target)
|
|
query.Set("before", before)
|
|
query.Set("limit", strconv.Itoa(limit))
|
|
|
|
resp, err := doRequestAuth(
|
|
tserver.t, http.MethodGet,
|
|
tserver.url("/api/v1/history?"+query.Encode()),
|
|
token, nil,
|
|
)
|
|
if err != nil {
|
|
tserver.t.Fatalf("get history: %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return resp.StatusCode, nil
|
|
}
|
|
|
|
var msgs []db.IRCMessage
|
|
|
|
decErr := json.NewDecoder(resp.Body).Decode(&msgs)
|
|
if decErr != nil {
|
|
tserver.t.Fatalf("decode history: %v", decErr)
|
|
}
|
|
|
|
return resp.StatusCode, msgs
|
|
}
|
|
|
|
// getAllHistory pages back through target's history
|
|
// pageSize messages at a time and returns the message texts,
|
|
// oldest first, and the number of pages that held any. It
|
|
// stops after maxPages pages, so a before that is ignored
|
|
// cannot loop forever.
|
|
func (tserver *testServer) getAllHistory(
|
|
token, target string,
|
|
pageSize, maxPages int,
|
|
) ([]string, int) {
|
|
tserver.t.Helper()
|
|
|
|
var (
|
|
got []string
|
|
pages int
|
|
)
|
|
|
|
// The first page has no before; each later one passes the
|
|
// id of the oldest message on the page fetched before it.
|
|
before := ""
|
|
|
|
for range maxPages {
|
|
status, page := tserver.getHistory(
|
|
token, target, before, pageSize,
|
|
)
|
|
if status != http.StatusOK {
|
|
tserver.t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
if len(page) == 0 {
|
|
break
|
|
}
|
|
|
|
pages++
|
|
|
|
texts := make([]string, 0, len(page))
|
|
|
|
for _, msg := range page {
|
|
var body []string
|
|
|
|
err := json.Unmarshal(msg.Body, &body)
|
|
if err != nil {
|
|
tserver.t.Fatalf("decode body: %v", err)
|
|
}
|
|
|
|
texts = append(texts, body...)
|
|
}
|
|
|
|
got = slices.Insert(got, 0, texts...)
|
|
before = page[0].ID
|
|
}
|
|
|
|
return got, pages
|
|
}
|
|
|
|
func TestHistoryPaging(t *testing.T) {
|
|
const (
|
|
channel = "#paging"
|
|
msgCount = 5
|
|
pageSize = 2
|
|
wantPages = 3
|
|
)
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("pager")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
want := make([]string, 0, msgCount)
|
|
|
|
for i := range msgCount {
|
|
text := fmt.Sprintf("message %d", i+1)
|
|
want = append(want, text)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{text},
|
|
})
|
|
}
|
|
|
|
got, pages := tserver.getAllHistory(
|
|
token, channel, pageSize, msgCount,
|
|
)
|
|
|
|
if pages != wantPages {
|
|
t.Fatalf(
|
|
"expected %d pages, got %d", wantPages, pages,
|
|
)
|
|
}
|
|
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("expected %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
// TestHistoryDMPaging pages through a nick's DM history,
|
|
// which holds the DMs its session got under an earlier nick
|
|
// and none sent to another session, and checks that the id
|
|
// of a DM sent to another session is not accepted as before.
|
|
func TestHistoryDMPaging(t *testing.T) {
|
|
const (
|
|
firstNick = "readerFirst"
|
|
laterNick = "readerLater"
|
|
otherNick = "otherReader"
|
|
// DMs sent to firstNick before the nick change.
|
|
sentEarly = 2
|
|
pageSize = 2
|
|
wantPages = 3
|
|
)
|
|
|
|
tserver := newTestServer(t)
|
|
senderToken := tserver.createSession("dmPager")
|
|
readerToken := tserver.createSession(firstNick)
|
|
otherToken := tserver.createSession(otherNick)
|
|
|
|
sendDM := func(to, text string) {
|
|
tserver.sendCommand(senderToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: to,
|
|
bodyKey: []string{text},
|
|
})
|
|
}
|
|
|
|
want := []string{"dm 1", "dm 2", "dm 3", "dm 4", "dm 5"}
|
|
|
|
for _, text := range want[:sentEarly] {
|
|
sendDM(firstNick, text)
|
|
}
|
|
|
|
_, result := tserver.sendCommand(readerToken, map[string]any{
|
|
commandKey: irc.CmdNick,
|
|
bodyKey: []string{laterNick},
|
|
})
|
|
if result[nickKey] != laterNick {
|
|
t.Fatalf("expected nick %s, got %v", laterNick, result)
|
|
}
|
|
|
|
sendDM(otherNick, "not for the reader")
|
|
|
|
for _, text := range want[sentEarly:] {
|
|
sendDM(laterNick, text)
|
|
}
|
|
|
|
got, pages := tserver.getAllHistory(
|
|
readerToken, laterNick, pageSize, len(want),
|
|
)
|
|
|
|
if pages != wantPages {
|
|
t.Fatalf(
|
|
"expected %d pages, got %d", wantPages, pages,
|
|
)
|
|
}
|
|
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("expected %q, got %q", want, got)
|
|
}
|
|
|
|
_, otherDMs := tserver.getHistory(otherToken, otherNick, "", 1)
|
|
if len(otherDMs) != 1 {
|
|
t.Fatalf("expected 1 DM, got %d", len(otherDMs))
|
|
}
|
|
|
|
status, _ := tserver.getHistory(
|
|
readerToken, laterNick, otherDMs[0].ID, pageSize,
|
|
)
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestHistoryUnknownBefore(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("lost")
|
|
|
|
for _, channel := range []string{"#here", "#elsewhere"} {
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"hi there"},
|
|
})
|
|
}
|
|
|
|
_, elsewhere := tserver.getHistory(
|
|
token, "#elsewhere", "", 1,
|
|
)
|
|
if len(elsewhere) != 1 {
|
|
t.Fatalf(
|
|
"expected 1 message, got %d", len(elsewhere),
|
|
)
|
|
}
|
|
|
|
// History holds only PRIVMSG, so the id of the JOIN to
|
|
// #here is not in it.
|
|
var joinID string
|
|
|
|
queued, _ := tserver.pollMessages(token, 0)
|
|
for _, msg := range queued {
|
|
if msg[commandKey] == joinCmd && msg[toKey] == "#here" {
|
|
joinID, _ = msg["id"].(string)
|
|
}
|
|
}
|
|
|
|
if joinID == "" {
|
|
t.Fatal("expected a JOIN to #here in the queue")
|
|
}
|
|
|
|
cases := []struct{ name, before string }{
|
|
{"made-up id", "no-such-message"},
|
|
{"id from another channel", elsewhere[0].ID},
|
|
{"id of a JOIN", joinID},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
status, _ := tserver.getHistory(
|
|
token, "#here", tc.before, 1,
|
|
)
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf(
|
|
"%s: expected 400, got %d", tc.name, status,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHistoryReusedNick checks that a user who takes a nick
|
|
// released by QUIT does not get the DMs sent to its previous
|
|
// holder.
|
|
func TestHistoryReusedNick(t *testing.T) {
|
|
const victim = "victimV"
|
|
|
|
tserver := newTestServer(t)
|
|
senderToken := tserver.createSession("dmA")
|
|
victimToken := tserver.createSession(victim)
|
|
newcomerToken := tserver.createSession("newcomer")
|
|
|
|
tserver.sendCommand(senderToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: victim,
|
|
bodyKey: []string{"for the first victimV only"},
|
|
})
|
|
|
|
_, msgs := tserver.getHistory(victimToken, victim, "", 10)
|
|
if len(msgs) != 1 {
|
|
t.Fatalf("expected 1 DM for %s, got %d", victim, len(msgs))
|
|
}
|
|
|
|
tserver.sendCommand(victimToken, map[string]any{
|
|
commandKey: irc.CmdQuit,
|
|
})
|
|
|
|
_, result := tserver.sendCommand(newcomerToken, map[string]any{
|
|
commandKey: irc.CmdNick,
|
|
bodyKey: []string{victim},
|
|
})
|
|
if result[nickKey] != victim {
|
|
t.Fatalf("expected nick %s, got %v", victim, result)
|
|
}
|
|
|
|
status, msgs := tserver.getHistory(newcomerToken, victim, "", 10)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
if len(msgs) != 0 {
|
|
t.Fatalf("expected no history, got %v", msgs)
|
|
}
|
|
}
|
|
|
|
// TestHistoryRecreatedChannel checks that a channel created
|
|
// again under the name of a deleted one starts with no
|
|
// history, even when the old one had a key.
|
|
func TestHistoryRecreatedChannel(t *testing.T) {
|
|
const channel = "#priv"
|
|
|
|
tserver := newTestServer(t)
|
|
ownerToken := tserver.createSession("roomR")
|
|
strangerToken := tserver.createSession("stranger")
|
|
|
|
tserver.sendCommand(ownerToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(ownerToken, map[string]any{
|
|
commandKey: irc.CmdMode,
|
|
toKey: channel,
|
|
bodyKey: []string{"+k", "topsecret"},
|
|
})
|
|
tserver.sendCommand(ownerToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"members only"},
|
|
})
|
|
|
|
_, msgs := tserver.getHistory(ownerToken, channel, "", 10)
|
|
if len(msgs) != 1 {
|
|
t.Fatalf("expected 1 message, got %d", len(msgs))
|
|
}
|
|
|
|
// The last member leaving deletes the channel.
|
|
tserver.sendCommand(ownerToken, map[string]any{
|
|
commandKey: irc.CmdPart, toKey: channel,
|
|
})
|
|
|
|
_, result := tserver.sendCommand(strangerToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
if result[statusKey] != joinedStatus {
|
|
t.Fatalf("expected the join to succeed, got %v", result)
|
|
}
|
|
|
|
status, msgs := tserver.getHistory(strangerToken, channel, "", 10)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
if len(msgs) != 0 {
|
|
t.Fatalf("expected no history, got %v", msgs)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
const channel = "#longpoll"
|
|
|
|
tserver := newTestServer(t)
|
|
aliceToken := tserver.createSession("lp_alice")
|
|
bobToken := tserver.createSession("lp_bob")
|
|
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(bobToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
_, 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: channel,
|
|
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) {
|
|
const channel = "#ephemeral"
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("ephemeral")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "PART", toKey: channel,
|
|
})
|
|
|
|
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 _, listed := range channels {
|
|
if listed["name"] == channel {
|
|
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{nickKey: 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: irc.CmdPass,
|
|
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: irc.CmdPass,
|
|
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: irc.CmdPass},
|
|
)
|
|
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: irc.CmdPass,
|
|
bodyKey: []string{testPassword},
|
|
})
|
|
|
|
// Login with nick + password.
|
|
loginBody, err := json.Marshal(map[string]string{
|
|
nickKey: "loginuser", passwordKey: testPassword,
|
|
})
|
|
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[nickKey] != "loginuser" {
|
|
t.Fatalf(
|
|
"expected loginuser, got %v",
|
|
state[nickKey],
|
|
)
|
|
}
|
|
}
|
|
|
|
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: irc.CmdPass,
|
|
bodyKey: []string{"correctpass1"},
|
|
})
|
|
|
|
postJSONExpectStatus(
|
|
t, tserver, "/api/v1/login",
|
|
map[string]string{
|
|
nickKey: "wrongpwuser",
|
|
passwordKey: "wrongpass12",
|
|
},
|
|
http.StatusUnauthorized,
|
|
)
|
|
}
|
|
|
|
func TestLoginNonexistentUser(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
postJSONExpectStatus(
|
|
t, tserver, "/api/v1/login",
|
|
map[string]string{
|
|
nickKey: "ghostuser",
|
|
passwordKey: testPassword,
|
|
},
|
|
http.StatusUnauthorized,
|
|
)
|
|
}
|
|
|
|
func TestSessionCookie(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
body, err := json.Marshal(
|
|
map[string]string{nickKey: "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")
|
|
}
|
|
|
|
// Secure even over plain HTTP, as this test server is.
|
|
if !authCookie.Secure {
|
|
t.Fatal("cookie should be Secure")
|
|
}
|
|
|
|
// 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[nickKey] != "anon_user" {
|
|
t.Fatalf(
|
|
"expected anon_user, got %v",
|
|
state[nickKey],
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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: irc.CmdWhois,
|
|
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{
|
|
nickKey: 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) {
|
|
const channel = "#whotest"
|
|
|
|
tserver := newTestServer(t)
|
|
|
|
whoToken := tserver.createSessionWithUsername(
|
|
"whouser", "whoident",
|
|
)
|
|
|
|
tserver.sendCommand(whoToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
queryToken := tserver.createSession("whoquerier")
|
|
|
|
tserver.sendCommand(queryToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(queryToken, 0)
|
|
|
|
tserver.sendCommand(queryToken, map[string]any{
|
|
commandKey: "WHO",
|
|
toKey: channel,
|
|
})
|
|
|
|
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: irc.CmdWhois,
|
|
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{
|
|
nickKey: "nosuchuser",
|
|
passwordKey: "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{
|
|
nickKey: "nosuchuser", passwordKey: "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: irc.CmdPass,
|
|
bodyKey: []string{testPassword},
|
|
})
|
|
|
|
// A single login should succeed without rate limiting.
|
|
loginBody, err := json.Marshal(map[string]string{
|
|
nickKey: "normaluser", passwordKey: testPassword,
|
|
})
|
|
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: irc.CmdNick,
|
|
bodyKey: []string{"nick_a_new"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(bobToken, lastID)
|
|
|
|
if !findMessage(msgs, irc.CmdNick, "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) {
|
|
const channel = "#hcquery"
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("hcquery_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Set hashcash bits.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+H", "5"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Query mode — should show +nH.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
})
|
|
|
|
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) {
|
|
const channel = "#hcclear"
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("hcclear_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Set hashcash bits.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+H", "5"},
|
|
})
|
|
|
|
// Clear hashcash bits.
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
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: channel,
|
|
bodyKey: []string{"test message"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestChannelHashcashRejectNoStamp(t *testing.T) {
|
|
const channel = "#hcreject"
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("hcreject_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Set hashcash requirement.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
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: channel,
|
|
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) {
|
|
const (
|
|
channel = "#hcaccept"
|
|
text = "hello world"
|
|
)
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("hcaccept_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Set hashcash requirement (2 bits = fast to mint).
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+H", "2"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Mint a valid hashcash stamp.
|
|
msgBody, marshalErr := json.Marshal(
|
|
[]string{text},
|
|
)
|
|
if marshalErr != nil {
|
|
t.Fatal(marshalErr)
|
|
}
|
|
|
|
stamp := mintTestChannelHashcash(
|
|
t, 2, channel, msgBody,
|
|
)
|
|
|
|
// Send message with valid hashcash.
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{text},
|
|
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) {
|
|
const (
|
|
channel = "#hcreplay"
|
|
text = "unique msg"
|
|
)
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("hcreplay_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Set hashcash requirement.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+H", "2"},
|
|
})
|
|
|
|
_, _ = tserver.pollMessages(token, 0)
|
|
|
|
// Mint and send once — should succeed.
|
|
msgBody, marshalErr := json.Marshal(
|
|
[]string{text},
|
|
)
|
|
if marshalErr != nil {
|
|
t.Fatal(marshalErr)
|
|
}
|
|
|
|
stamp := mintTestChannelHashcash(
|
|
t, 2, channel, msgBody,
|
|
)
|
|
|
|
status, _ := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{text},
|
|
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: channel,
|
|
bodyKey: []string{text},
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
|
|
// sendConcurrently posts cmd in the given number of requests
|
|
// at once and returns the status field of each response.
|
|
func (tserver *testServer) sendConcurrently(
|
|
token string, cmd map[string]any, requests int,
|
|
) []any {
|
|
tserver.t.Helper()
|
|
|
|
body, err := json.Marshal(cmd)
|
|
if err != nil {
|
|
tserver.t.Fatalf("marshal command: %v", err)
|
|
}
|
|
|
|
// Each request writes only its own entry.
|
|
statuses := make([]any, requests)
|
|
start := make(chan struct{})
|
|
|
|
var waitGroup sync.WaitGroup
|
|
|
|
for idx := range requests {
|
|
waitGroup.Add(1)
|
|
|
|
go func() {
|
|
defer waitGroup.Done()
|
|
|
|
<-start
|
|
|
|
resp, reqErr := doRequestAuth(
|
|
tserver.t, http.MethodPost,
|
|
tserver.url(apiMessages), token,
|
|
bytes.NewReader(body),
|
|
)
|
|
if reqErr != nil {
|
|
tserver.t.Error(reqErr)
|
|
|
|
return
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
var result map[string]any
|
|
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
|
|
statuses[idx] = result[statusKey]
|
|
}()
|
|
}
|
|
|
|
close(start)
|
|
waitGroup.Wait()
|
|
|
|
return statuses
|
|
}
|
|
|
|
// TestChannelHashcashRejectConcurrentReplay sends one stamp
|
|
// in several requests at once and checks that exactly one is
|
|
// accepted and the other member receives exactly one copy.
|
|
func TestChannelHashcashRejectConcurrentReplay(t *testing.T) {
|
|
const (
|
|
channel = "#hcrace"
|
|
text = "one stamp"
|
|
sender = "hcrace_sender"
|
|
requests = 16
|
|
)
|
|
|
|
tserver := newTestServer(t)
|
|
senderToken := tserver.createSession(sender)
|
|
receiverToken := tserver.createSession("hcrace_receiver")
|
|
|
|
tserver.sendCommand(senderToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(receiverToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
tserver.sendCommand(senderToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+H", "2"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(receiverToken, 0)
|
|
|
|
msgBody, marshalErr := json.Marshal([]string{text})
|
|
if marshalErr != nil {
|
|
t.Fatal(marshalErr)
|
|
}
|
|
|
|
stamp := mintTestChannelHashcash(t, 2, channel, msgBody)
|
|
|
|
statuses := tserver.sendConcurrently(senderToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{text},
|
|
metaKey: map[string]any{hashcashKey: stamp},
|
|
}, requests)
|
|
|
|
accepted := 0
|
|
|
|
for _, status := range statuses {
|
|
if status == "sent" {
|
|
accepted++
|
|
}
|
|
}
|
|
|
|
if accepted != 1 {
|
|
t.Fatalf(
|
|
"expected 1 accepted message, got %d: %v",
|
|
accepted, statuses,
|
|
)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(receiverToken, lastID)
|
|
|
|
delivered := 0
|
|
|
|
for _, msg := range msgs {
|
|
if msg[commandKey] == privmsgCmd &&
|
|
msg["from"] == sender {
|
|
delivered++
|
|
}
|
|
}
|
|
|
|
if delivered != 1 {
|
|
t.Fatalf(
|
|
"expected 1 delivered copy, got %d: %v",
|
|
delivered, 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,
|
|
)
|
|
}
|
|
}
|
|
|
|
// postSessionWithStamp posts a session request for nick and
|
|
// username carrying stamp and returns the response status.
|
|
// An empty username defaults to the nick.
|
|
func postSessionWithStamp(
|
|
t *testing.T,
|
|
tserver *testServer,
|
|
nick, username, stamp string,
|
|
) int {
|
|
t.Helper()
|
|
|
|
body, err := json.Marshal(map[string]string{
|
|
nickKey: nick, "username": username, "pow_token": stamp,
|
|
})
|
|
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)
|
|
}
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
return resp.StatusCode
|
|
}
|
|
|
|
// sessionCount returns the number of sessions in tserver's
|
|
// database.
|
|
func sessionCount(t *testing.T, tserver *testServer) int64 {
|
|
t.Helper()
|
|
|
|
count, err := tserver.database.GetUserCount(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("count sessions: %v", err)
|
|
}
|
|
|
|
return count
|
|
}
|
|
|
|
// TestRefusedSessionLeavesStampUnspent checks that a
|
|
// session request is refused with 402 for an invalid stamp
|
|
// before its nick is looked at, that a request refused with
|
|
// 400 or 409 does not spend its stamp, so the same stamp
|
|
// then creates a session, that the stamp stays spent after
|
|
// that, that a spent stamp with an invalid nick is refused
|
|
// for its nick, and that no refused request creates a
|
|
// session.
|
|
func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
|
|
const (
|
|
bits = 2
|
|
badNick = "bad nick"
|
|
)
|
|
|
|
tserver := newTestServerWith(t, bits, "", "")
|
|
|
|
// A server with no SERVER_NAME takes stamps for "neoirc".
|
|
status := postSessionWithStamp(
|
|
t, tserver, "taken", "", api.MintHashcash(bits, "neoirc"),
|
|
)
|
|
if status != http.StatusCreated {
|
|
t.Fatalf("create taken: want 201, got %d", status)
|
|
}
|
|
|
|
stamp := api.MintHashcash(bits, "neoirc")
|
|
otherServerStamp := api.MintHashcash(bits, "elsewhere")
|
|
|
|
steps := []struct {
|
|
nick, username, stamp string
|
|
want int
|
|
}{
|
|
{badNick, "", otherServerStamp, http.StatusPaymentRequired},
|
|
{badNick, "", stamp, http.StatusBadRequest},
|
|
{"fresh", "bad username", stamp, http.StatusBadRequest},
|
|
{"taken", "", stamp, http.StatusConflict},
|
|
{"fresh", "", stamp, http.StatusCreated},
|
|
{"another", "", stamp, http.StatusPaymentRequired},
|
|
// Refused as already spent, the stamp is not given back.
|
|
{"later", "", stamp, http.StatusPaymentRequired},
|
|
// Whether the stamp is spent is looked at after the nick.
|
|
{badNick, "", stamp, http.StatusBadRequest},
|
|
// The refused request for "another" left the nick free.
|
|
{"another", "", api.MintHashcash(bits, "neoirc"), http.StatusCreated},
|
|
}
|
|
|
|
sessions := sessionCount(t, tserver)
|
|
|
|
for _, step := range steps {
|
|
status = postSessionWithStamp(
|
|
t, tserver, step.nick, step.username, step.stamp,
|
|
)
|
|
if status != step.want {
|
|
t.Fatalf(
|
|
"nick %q, username %q: want %d, got %d",
|
|
step.nick, step.username, step.want, status,
|
|
)
|
|
}
|
|
|
|
if status == http.StatusCreated {
|
|
sessions++
|
|
}
|
|
|
|
count := sessionCount(t, tserver)
|
|
if count != sessions {
|
|
t.Fatalf(
|
|
"nick %q, username %q: want %d sessions, got %d",
|
|
step.nick, step.username, sessions, count,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
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: irc.CmdNames,
|
|
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: irc.CmdOper,
|
|
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: irc.CmdOper,
|
|
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: irc.CmdOper,
|
|
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: irc.CmdOper,
|
|
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: irc.CmdWhois,
|
|
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: irc.CmdWhois,
|
|
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: irc.CmdOper,
|
|
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: irc.CmdWhois,
|
|
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: irc.CmdOper,
|
|
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: irc.CmdNames, 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) {
|
|
const channel = "#optest2"
|
|
|
|
tserver := newTestServer(t)
|
|
creatorToken := tserver.createSession("op_first")
|
|
joinerToken := tserver.createSession("op_second")
|
|
|
|
tserver.sendCommand(creatorToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
tserver.sendCommand(joinerToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(joinerToken, 0)
|
|
|
|
tserver.sendCommand(joinerToken, map[string]any{
|
|
commandKey: irc.CmdNames, toKey: channel,
|
|
})
|
|
|
|
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) {
|
|
const channel = "#modeop"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("granter")
|
|
targetToken := tserver.createSession("grantee")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(targetToken, 0)
|
|
|
|
// granter (creator = +o) grants +o to grantee.
|
|
status, _ := tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
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: irc.CmdNames, toKey: channel,
|
|
})
|
|
|
|
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) {
|
|
const channel = "#revoke"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("revoker")
|
|
targetToken := tserver.createSession("revokee")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Grant +o, then revoke it.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+o", "revokee"},
|
|
})
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"-o", "revokee"},
|
|
})
|
|
|
|
// Check NAMES — revokee should NOT have @.
|
|
_, lastID := tserver.pollMessages(opToken, 0)
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: irc.CmdNames, toKey: channel,
|
|
})
|
|
|
|
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) {
|
|
const channel = "#voice"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("voicer")
|
|
targetToken := tserver.createSession("voiced")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Grant +v.
|
|
status, _ := tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
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: irc.CmdNames, toKey: channel,
|
|
})
|
|
|
|
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: channel,
|
|
bodyKey: []string{"-v", "voiced"},
|
|
})
|
|
|
|
_, lastID = tserver.pollMessages(opToken, 0)
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: irc.CmdNames, toKey: channel,
|
|
})
|
|
|
|
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) {
|
|
const channel = "#noperm"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("realop")
|
|
nonOpToken := tserver.createSession("nonop")
|
|
targetToken := tserver.createSession("target3")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(nonOpToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(nonOpToken, 0)
|
|
|
|
// Non-op tries +o.
|
|
tserver.sendCommand(nonOpToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
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: channel,
|
|
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) {
|
|
const channel = "#moderated"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("modop")
|
|
regularToken := tserver.createSession("regular2")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Set +m.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
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: channel,
|
|
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) {
|
|
const channel = "#modop"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("modop2")
|
|
observerToken := tserver.createSession("observer2")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(observerToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Set +m.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+m"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(observerToken, 0)
|
|
|
|
// Op sends — should work.
|
|
status, result := tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: channel,
|
|
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) {
|
|
const channel = "#modvoice"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("modop3")
|
|
voicedToken := tserver.createSession("modvoiced")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(voicedToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Set +m and +v on voiced user.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+m"},
|
|
})
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
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: channel,
|
|
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) {
|
|
const channel = "#tlock"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("topicop")
|
|
regularToken := tserver.createSession("topicuser")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// +t is on by default. Non-op tries to set topic.
|
|
_, lastID := tserver.pollMessages(regularToken, 0)
|
|
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: irc.CmdTopic,
|
|
toKey: channel,
|
|
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: irc.CmdTopic,
|
|
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) {
|
|
const channel = "#tloff"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("tloff_op")
|
|
regularToken := tserver.createSession("tloff_user")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Disable +t.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"-t"},
|
|
})
|
|
|
|
// Now regular user sets topic — should succeed.
|
|
status, result := tserver.sendCommand(
|
|
regularToken, map[string]any{
|
|
commandKey: irc.CmdTopic,
|
|
toKey: channel,
|
|
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) {
|
|
const channel = "#kicktest"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("kicker")
|
|
targetToken := tserver.createSession("kicked")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(targetToken, 0)
|
|
|
|
// Kick the target.
|
|
status, _ := tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: kickCmd,
|
|
toKey: channel,
|
|
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: channel,
|
|
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) {
|
|
const channel = "#kickperm"
|
|
|
|
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: channel,
|
|
})
|
|
tserver.sendCommand(nonOpToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(nonOpToken, 0)
|
|
|
|
// Non-op tries to kick.
|
|
tserver.sendCommand(nonOpToken, map[string]any{
|
|
commandKey: kickCmd,
|
|
toKey: channel,
|
|
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) {
|
|
const channel = "#kickbc"
|
|
|
|
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: channel,
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(observerToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(observerToken, 0)
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: kickCmd,
|
|
toKey: channel,
|
|
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: irc.CmdNotice,
|
|
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) {
|
|
const channel = "#hcnotice"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("hcnotice_op")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Set hashcash requirement.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+H", "2"},
|
|
})
|
|
|
|
// Send NOTICE without hashcash — should succeed.
|
|
status, result := tserver.sendCommand(
|
|
opToken, map[string]any{
|
|
commandKey: irc.CmdNotice,
|
|
toKey: channel,
|
|
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) {
|
|
const channel = "#modnotice"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("modnotop")
|
|
regularToken := tserver.createSession("modnotice")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+m"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(regularToken, 0)
|
|
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: irc.CmdNotice,
|
|
toKey: channel,
|
|
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) {
|
|
const channel = "#modperm"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("setmod_op")
|
|
regularToken := tserver.createSession("setmod_reg")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(regularToken, 0)
|
|
|
|
tserver.sendCommand(regularToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
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)
|
|
|
|
if !slices.Contains(params, "PREFIX=(ov)@+") {
|
|
t.Fatalf(
|
|
"expected PREFIX=(ov)@+ in ISUPPORT, "+
|
|
"got %v",
|
|
params,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestModeQueryShowsModerated verifies MODE query shows
|
|
// +m when set.
|
|
func TestModeQueryShowsModerated(t *testing.T) {
|
|
const channel = "#mqtest"
|
|
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("mquery")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+m"},
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: modeCmd, toKey: channel,
|
|
})
|
|
|
|
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) {
|
|
const channel = "#kickdef"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("kickdefop")
|
|
targetToken := tserver.createSession("kickdeftg")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(targetToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(targetToken, 0)
|
|
|
|
// Kick with only nick, no reason.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: kickCmd,
|
|
toKey: channel,
|
|
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) {
|
|
const channel = "#bans"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("banop")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Add a ban.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
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: channel,
|
|
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: channel,
|
|
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: channel,
|
|
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) {
|
|
const channel = "#banjoin"
|
|
|
|
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: channel,
|
|
})
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+b", "banned2!*@*"},
|
|
})
|
|
|
|
// Banned user tries to join.
|
|
_, lastID := tserver.pollMessages(userToken, 0)
|
|
tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
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) {
|
|
const channel = "#banmsg"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("banmsgop")
|
|
userToken := tserver.createSession("banmsgusr")
|
|
|
|
// Both join.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Op bans the user.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+b", "banmsgusr!*@*"},
|
|
})
|
|
|
|
// User tries to send a message.
|
|
_, lastID := tserver.pollMessages(userToken, 0)
|
|
tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: channel,
|
|
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) {
|
|
const channel = "#invonly"
|
|
|
|
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: channel,
|
|
})
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+i"},
|
|
})
|
|
|
|
// User tries to join without invite.
|
|
_, lastID := tserver.pollMessages(userToken, 0)
|
|
tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
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", channel},
|
|
})
|
|
|
|
// User tries again — should succeed with invite.
|
|
_, result := tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
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) {
|
|
const channel = "#secret"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("secop")
|
|
outsiderToken := tserver.createSession("secout")
|
|
|
|
// Op creates secret channel.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
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 == channel {
|
|
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 == channel {
|
|
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) {
|
|
const channel = "#keyed"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("keyop")
|
|
userToken := tserver.createSession("keyusr")
|
|
|
|
// Op creates keyed channel.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+k", "mykey"},
|
|
})
|
|
|
|
// User tries without key.
|
|
_, lastID := tserver.pollMessages(userToken, 0)
|
|
tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
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: channel,
|
|
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: channel,
|
|
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) {
|
|
const channel = "#limited"
|
|
|
|
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: channel,
|
|
})
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: []string{"+l", "2"},
|
|
})
|
|
|
|
// User1 joins — should succeed (2 members now: op + user1).
|
|
_, result := tserver.sendCommand(user1Token, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
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: channel,
|
|
})
|
|
|
|
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) {
|
|
const channel = "#modestr"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("modestrop")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
// Set all tier 2 modes.
|
|
for _, modeChange := range [][]string{
|
|
{"+i"}, {"+s"}, {"+k", "pw"}, {"+l", "50"},
|
|
} {
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd,
|
|
toKey: channel,
|
|
bodyKey: modeChange,
|
|
})
|
|
}
|
|
|
|
_, lastID := tserver.pollMessages(opToken, 0)
|
|
|
|
// Query mode.
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: modeCmd, toKey: channel,
|
|
})
|
|
|
|
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) {
|
|
const channel = "#noperm"
|
|
|
|
tserver := newTestServer(t)
|
|
opToken := tserver.createSession("modeopx")
|
|
userToken := tserver.createSession("modeusrx")
|
|
|
|
tserver.sendCommand(opToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
tserver.sendCommand(userToken, map[string]any{
|
|
commandKey: joinCmd, toKey: channel,
|
|
})
|
|
|
|
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: channel,
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
}
|