All checks were successful
check / check (push) Successful in 2m19s
Security: - Add channel membership check before PRIVMSG (prevents non-members from sending) - Add membership check on history endpoint (channels require membership, DMs scoped to own nick) - Enforce MaxBytesReader on all POST request bodies - Fix rand.Read error being silently ignored in token generation Data integrity: - Fix TOCTOU race in GetOrCreateChannel using INSERT OR IGNORE + SELECT Build: - Add CGO_ENABLED=0 to golangci-lint install in Dockerfile (fixes alpine build) Linting: - Strict .golangci.yml: only wsl disabled (deprecated in v2) - Re-enable exhaustruct, depguard, godot, wrapcheck, varnamelen - Fix linters-settings -> linters.settings for v2 config format - Fix ALL lint findings in actual code (no linter config weakening) - Wrap all external package errors (wrapcheck) - Fill struct fields or add targeted nolint:exhaustruct where appropriate - Rename short variables (ts->timestamp, n->bufIndex, etc.) - Add depguard deny policy for io/ioutil and math/rand - Exclude G704 (SSRF) in gosec config (CLI client takes user-configured URLs) Tests: - Add security tests (TestNonMemberCannotSend, TestHistoryNonMember) - Split TestInsertAndPollMessages for reduced complexity - Fix parallel test safety (viper global state prevents parallelism) - Use t.Context() instead of context.Background() in tests Docker build verified passing locally.
1499 lines
29 KiB
Go
1499 lines
29 KiB
Go
// Tests use a global viper instance for configuration,
|
|
// making parallel execution unsafe.
|
|
//
|
|
//nolint:paralleltest
|
|
package handlers_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/chat/internal/config"
|
|
"git.eeqj.de/sneak/chat/internal/db"
|
|
"git.eeqj.de/sneak/chat/internal/globals"
|
|
"git.eeqj.de/sneak/chat/internal/handlers"
|
|
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
|
"git.eeqj.de/sneak/chat/internal/logger"
|
|
"git.eeqj.de/sneak/chat/internal/middleware"
|
|
"git.eeqj.de/sneak/chat/internal/server"
|
|
"go.uber.org/fx"
|
|
"go.uber.org/fx/fxtest"
|
|
)
|
|
|
|
const (
|
|
commandKey = "command"
|
|
bodyKey = "body"
|
|
toKey = "to"
|
|
statusKey = "status"
|
|
privmsgCmd = "PRIVMSG"
|
|
joinCmd = "JOIN"
|
|
apiMessages = "/api/v1/messages"
|
|
apiSession = "/api/v1/session"
|
|
apiState = "/api/v1/state"
|
|
)
|
|
|
|
// testServer wraps a test HTTP server with helpers.
|
|
type testServer struct {
|
|
httpServer *httptest.Server
|
|
t *testing.T
|
|
fxApp *fxtest.App
|
|
}
|
|
|
|
func newTestServer(
|
|
t *testing.T,
|
|
) *testServer {
|
|
t.Helper()
|
|
|
|
dbPath := filepath.Join(
|
|
t.TempDir(), "test.db",
|
|
)
|
|
|
|
dbURL := "file:" + dbPath +
|
|
"?_journal_mode=WAL&_busy_timeout=5000"
|
|
|
|
var srv *server.Server
|
|
|
|
app := fxtest.New(t,
|
|
fx.Provide(
|
|
newTestGlobals,
|
|
logger.New,
|
|
func(
|
|
lifecycle fx.Lifecycle,
|
|
globs *globals.Globals,
|
|
log *logger.Logger,
|
|
) (*config.Config, error) {
|
|
cfg, err := config.New(
|
|
lifecycle, config.Params{ //nolint:exhaustruct
|
|
Globals: globs, Logger: log,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"test config: %w", err,
|
|
)
|
|
}
|
|
|
|
cfg.DBURL = dbURL
|
|
cfg.Port = 0
|
|
|
|
return cfg, nil
|
|
},
|
|
newTestDB,
|
|
newTestHealthcheck,
|
|
newTestMiddleware,
|
|
newTestHandlers,
|
|
newTestServerFx,
|
|
),
|
|
fx.Populate(&srv),
|
|
)
|
|
|
|
app.RequireStart()
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
httpSrv := httptest.NewServer(srv)
|
|
|
|
t.Cleanup(func() {
|
|
httpSrv.Close()
|
|
app.RequireStop()
|
|
})
|
|
|
|
return &testServer{
|
|
httpServer: httpSrv,
|
|
t: t,
|
|
fxApp: app,
|
|
}
|
|
}
|
|
|
|
func newTestGlobals() *globals.Globals {
|
|
return &globals.Globals{
|
|
Appname: "chat-test",
|
|
Version: "test",
|
|
}
|
|
}
|
|
|
|
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,
|
|
) (*healthcheck.Healthcheck, error) {
|
|
hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct
|
|
Globals: globs,
|
|
Config: cfg,
|
|
Logger: log,
|
|
Database: database,
|
|
})
|
|
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,
|
|
) (*handlers.Handlers, error) {
|
|
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
|
Logger: log,
|
|
Globals: globs,
|
|
Config: cfg,
|
|
Database: database,
|
|
Healthcheck: hcheck,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("test handlers: %w", err)
|
|
}
|
|
|
|
return hdlr, nil
|
|
}
|
|
|
|
func newTestServerFx(
|
|
lifecycle fx.Lifecycle,
|
|
log *logger.Logger,
|
|
globs *globals.Globals,
|
|
cfg *config.Config,
|
|
mware *middleware.Middleware,
|
|
hdlr *handlers.Handlers,
|
|
) (*server.Server, error) {
|
|
srv, err := server.New(lifecycle, server.Params{ //nolint:exhaustruct
|
|
Logger: log,
|
|
Globals: globs,
|
|
Config: cfg,
|
|
Middleware: mware,
|
|
Handlers: hdlr,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("test server: %w", err)
|
|
}
|
|
|
|
return srv, nil
|
|
}
|
|
|
|
func (tserver *testServer) url(path string) string {
|
|
return tserver.httpServer.URL + path
|
|
}
|
|
|
|
func doRequest(
|
|
t *testing.T,
|
|
method, url string,
|
|
body io.Reader,
|
|
) (*http.Response, error) {
|
|
t.Helper()
|
|
|
|
request, err := http.NewRequestWithContext(
|
|
t.Context(), method, url, body,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new request: %w", err)
|
|
}
|
|
|
|
if body != nil {
|
|
request.Header.Set(
|
|
"Content-Type", "application/json",
|
|
)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func doRequestAuth(
|
|
t *testing.T,
|
|
method, url, token 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 token != "" {
|
|
request.Header.Set(
|
|
"Authorization", "Bearer "+token,
|
|
)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func (tserver *testServer) createSession(
|
|
nick string,
|
|
) string {
|
|
tserver.t.Helper()
|
|
|
|
body, err := json.Marshal(
|
|
map[string]string{"nick": nick},
|
|
)
|
|
if err != nil {
|
|
tserver.t.Fatalf("marshal session: %v", err)
|
|
}
|
|
|
|
resp, err := doRequest(
|
|
tserver.t,
|
|
http.MethodPost,
|
|
tserver.url(apiSession),
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
tserver.t.Fatalf("create session: %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
tserver.t.Fatalf(
|
|
"create session: status %d: %s",
|
|
resp.StatusCode, respBody,
|
|
)
|
|
}
|
|
|
|
var result struct {
|
|
ID int64 `json:"id"`
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
|
if decErr != nil {
|
|
tserver.t.Fatalf("decode session: %v", decErr)
|
|
}
|
|
|
|
return result.Token
|
|
}
|
|
|
|
func (tserver *testServer) sendCommand(
|
|
token string, cmd map[string]any,
|
|
) (int, map[string]any) {
|
|
tserver.t.Helper()
|
|
|
|
body, err := json.Marshal(cmd)
|
|
if err != nil {
|
|
tserver.t.Fatalf("marshal command: %v", err)
|
|
}
|
|
|
|
resp, err := doRequestAuth(
|
|
tserver.t,
|
|
http.MethodPost,
|
|
tserver.url(apiMessages),
|
|
token,
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
tserver.t.Fatalf("send command: %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
var result map[string]any
|
|
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
|
|
return resp.StatusCode, result
|
|
}
|
|
|
|
func (tserver *testServer) getState(
|
|
token string,
|
|
) (int, map[string]any) {
|
|
tserver.t.Helper()
|
|
|
|
resp, err := doRequestAuth(
|
|
tserver.t,
|
|
http.MethodGet,
|
|
tserver.url(apiState),
|
|
token,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
tserver.t.Fatalf("get: %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
var result map[string]any
|
|
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
|
|
return resp.StatusCode, result
|
|
}
|
|
|
|
func (tserver *testServer) pollMessages(
|
|
token string, afterID int64,
|
|
) ([]map[string]any, int64) {
|
|
tserver.t.Helper()
|
|
|
|
pollURL := fmt.Sprintf(
|
|
"%s"+apiMessages+"?timeout=0&after=%d",
|
|
tserver.httpServer.URL, afterID,
|
|
)
|
|
|
|
resp, err := doRequestAuth(
|
|
tserver.t,
|
|
http.MethodGet,
|
|
pollURL,
|
|
token,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
tserver.t.Fatalf("poll: %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
var result struct {
|
|
Messages []map[string]any `json:"messages"`
|
|
LastID json.Number `json:"last_id"` //nolint:tagliatelle
|
|
}
|
|
|
|
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
|
if decErr != nil {
|
|
tserver.t.Fatalf("decode poll: %v", decErr)
|
|
}
|
|
|
|
lastID, _ := result.LastID.Int64()
|
|
|
|
return result.Messages, lastID
|
|
}
|
|
|
|
func postSession(
|
|
t *testing.T,
|
|
tserver *testServer,
|
|
nick string,
|
|
) *http.Response {
|
|
t.Helper()
|
|
|
|
body, err := json.Marshal(
|
|
map[string]string{"nick": nick},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodPost,
|
|
tserver.url(apiSession),
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
func findMessage(
|
|
msgs []map[string]any,
|
|
command, from string,
|
|
) bool {
|
|
for _, msg := range msgs {
|
|
if msg[commandKey] == command &&
|
|
msg["from"] == from {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
func TestCreateSessionValid(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("alice")
|
|
|
|
if token == "" {
|
|
t.Fatal("expected token")
|
|
}
|
|
}
|
|
|
|
func TestCreateSessionDuplicate(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
tserver.createSession("alice")
|
|
|
|
resp := postSession(t, tserver, "alice")
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("expected 409, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestCreateSessionEmpty(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
resp := postSession(t, tserver, "")
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf(
|
|
"expected 400, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestCreateSessionInvalidChars(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
resp := postSession(t, tserver, "hello world")
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf(
|
|
"expected 400, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestCreateSessionNumericStart(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
resp := postSession(t, tserver, "123abc")
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf(
|
|
"expected 400, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestCreateSessionMalformed(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodPost,
|
|
tserver.url(apiSession),
|
|
strings.NewReader("{bad"),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf(
|
|
"expected 400, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestAuthNoHeader(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
status, _ := tserver.getState("")
|
|
if status != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestAuthBadToken(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
status, _ := tserver.getState(
|
|
"invalid-token-12345",
|
|
)
|
|
if status != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestAuthValidToken(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("authtest")
|
|
|
|
status, result := tserver.getState(token)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
if result["nick"] != "authtest" {
|
|
t.Fatalf(
|
|
"expected nick authtest, got %v",
|
|
result["nick"],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestJoinChannel(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("joiner")
|
|
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: joinCmd, toKey: "#test",
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["channel"] != "#test" {
|
|
t.Fatalf(
|
|
"expected #test, got %v", result["channel"],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestJoinWithoutHash(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("joiner2")
|
|
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: joinCmd, toKey: "other",
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["channel"] != "#other" {
|
|
t.Fatalf(
|
|
"expected #other, got %v",
|
|
result["channel"],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestPartChannel(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("parter")
|
|
|
|
tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: joinCmd, toKey: "#test",
|
|
},
|
|
)
|
|
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: "PART", toKey: "#test",
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["channel"] != "#test" {
|
|
t.Fatalf(
|
|
"expected #test, got %v", result["channel"],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestJoinMissingTo(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("joiner3")
|
|
|
|
status, _ := tserver.sendCommand(
|
|
token, map[string]any{commandKey: joinCmd},
|
|
)
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestChannelMessage(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
aliceToken := tserver.createSession("alice_msg")
|
|
bobToken := tserver.createSession("bob_msg")
|
|
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#chat",
|
|
})
|
|
tserver.sendCommand(bobToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#chat",
|
|
})
|
|
|
|
_, _ = tserver.pollMessages(aliceToken, 0)
|
|
_, bobLastID := tserver.pollMessages(bobToken, 0)
|
|
|
|
status, result := tserver.sendCommand(
|
|
aliceToken,
|
|
map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#chat",
|
|
bodyKey: []string{"hello world"},
|
|
},
|
|
)
|
|
if status != http.StatusCreated {
|
|
t.Fatalf(
|
|
"expected 201, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["id"] == nil || result["id"] == "" {
|
|
t.Fatal("expected message id")
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(
|
|
bobToken, bobLastID,
|
|
)
|
|
if !findMessage(msgs, privmsgCmd, "alice_msg") {
|
|
t.Fatalf(
|
|
"bob didn't receive alice's message: %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestMessageMissingBody(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("nobody")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#chat",
|
|
})
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: privmsgCmd, toKey: "#chat",
|
|
})
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestMessageMissingTo(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("noto")
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
bodyKey: []string{"hello"},
|
|
})
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", status)
|
|
}
|
|
}
|
|
|
|
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",
|
|
})
|
|
|
|
// Alice tries to send without joining.
|
|
status, _ := tserver.sendCommand(
|
|
aliceToken,
|
|
map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#private",
|
|
bodyKey: []string{"sneaky"},
|
|
},
|
|
)
|
|
if status != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d", status)
|
|
}
|
|
}
|
|
|
|
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.StatusCreated {
|
|
t.Fatalf(
|
|
"expected 201, 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")
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "nobody",
|
|
bodyKey: []string{"hello?"},
|
|
})
|
|
if status != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestNickChange(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("nick_test")
|
|
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: "NICK",
|
|
bodyKey: []string{"newnick"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["nick"] != "newnick" {
|
|
t.Fatalf(
|
|
"expected newnick, got %v", result["nick"],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestNickSameAsCurrent(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("same_nick")
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: "NICK",
|
|
bodyKey: []string{"same_nick"},
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestNickCollision(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("nickuser")
|
|
|
|
tserver.createSession("taken_nick")
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: "NICK",
|
|
bodyKey: []string{"taken_nick"},
|
|
})
|
|
if status != http.StatusConflict {
|
|
t.Fatalf("expected 409, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestNickInvalid(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("nickval")
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: "NICK",
|
|
bodyKey: []string{"bad nick!"},
|
|
})
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestNickEmptyBody(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("nicknobody")
|
|
|
|
status, _ := tserver.sendCommand(
|
|
token, map[string]any{commandKey: "NICK"},
|
|
)
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestTopic(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("topic_user")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#topictest",
|
|
})
|
|
|
|
status, result := tserver.sendCommand(
|
|
token,
|
|
map[string]any{
|
|
commandKey: "TOPIC",
|
|
toKey: "#topictest",
|
|
bodyKey: []string{"Hello World Topic"},
|
|
},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
if result["topic"] != "Hello World Topic" {
|
|
t.Fatalf(
|
|
"expected topic, got %v", result["topic"],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestTopicMissingTo(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("topicnoto")
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: "TOPIC",
|
|
bodyKey: []string{"topic"},
|
|
})
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestTopicMissingBody(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("topicnobody")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#topictest",
|
|
})
|
|
|
|
status, _ := tserver.sendCommand(token, map[string]any{
|
|
commandKey: "TOPIC", toKey: "#topictest",
|
|
})
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestPing(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("ping_user")
|
|
|
|
status, result := tserver.sendCommand(
|
|
token, map[string]any{commandKey: "PING"},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", status)
|
|
}
|
|
|
|
if result[commandKey] != "PONG" {
|
|
t.Fatalf(
|
|
"expected PONG, got %v",
|
|
result[commandKey],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestQuit(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("quitter")
|
|
observerToken := tserver.createSession("observer")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#quitchan",
|
|
})
|
|
tserver.sendCommand(observerToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#quitchan",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(observerToken, 0)
|
|
|
|
status, result := tserver.sendCommand(
|
|
token, map[string]any{commandKey: "QUIT"},
|
|
)
|
|
if status != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d: %v", status, result,
|
|
)
|
|
}
|
|
|
|
msgs, _ := tserver.pollMessages(
|
|
observerToken, lastID,
|
|
)
|
|
if !findMessage(msgs, "QUIT", "quitter") {
|
|
t.Fatalf(
|
|
"observer didn't get QUIT: %v", msgs,
|
|
)
|
|
}
|
|
|
|
afterStatus, _ := tserver.getState(token)
|
|
if afterStatus != http.StatusUnauthorized {
|
|
t.Fatalf(
|
|
"expected 401 after quit, got %d",
|
|
afterStatus,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestUnknownCommand(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("cmdtest")
|
|
|
|
status, _ := tserver.sendCommand(
|
|
token, map[string]any{commandKey: "BOGUS"},
|
|
)
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestEmptyCommand(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("emptycmd")
|
|
|
|
status, _ := tserver.sendCommand(
|
|
token, map[string]any{commandKey: ""},
|
|
)
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestHistory(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("historian")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#history",
|
|
})
|
|
|
|
for range 5 {
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#history",
|
|
bodyKey: []string{"test message"},
|
|
})
|
|
}
|
|
|
|
histURL := tserver.url(
|
|
"/api/v1/history?target=%23history&limit=3",
|
|
)
|
|
|
|
resp, err := doRequestAuth(
|
|
t, http.MethodGet, histURL, token, nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
|
|
var msgs []map[string]any
|
|
|
|
decErr := json.NewDecoder(resp.Body).Decode(&msgs)
|
|
if decErr != nil {
|
|
t.Fatalf("decode history: %v", decErr)
|
|
}
|
|
|
|
if len(msgs) != 3 {
|
|
t.Fatalf("expected 3 messages, got %d", len(msgs))
|
|
}
|
|
}
|
|
|
|
func TestHistoryNonMember(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
aliceToken := tserver.createSession("alice_hist")
|
|
bobToken := tserver.createSession("bob_hist")
|
|
|
|
// Alice creates and joins a channel.
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#secret",
|
|
})
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#secret",
|
|
bodyKey: []string{"secret stuff"},
|
|
})
|
|
|
|
// Bob tries to read history without joining.
|
|
histURL := tserver.url(
|
|
"/api/v1/history?target=%23secret",
|
|
)
|
|
|
|
resp, err := doRequestAuth(
|
|
t, http.MethodGet, histURL, bobToken, nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf(
|
|
"expected 403, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestChannelList(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("lister")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#listchan",
|
|
})
|
|
|
|
resp, err := doRequestAuth(
|
|
t,
|
|
http.MethodGet,
|
|
tserver.url("/api/v1/channels"),
|
|
token,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
|
|
var channels []map[string]any
|
|
|
|
decErr := json.NewDecoder(resp.Body).Decode(
|
|
&channels,
|
|
)
|
|
if decErr != nil {
|
|
t.Fatalf("decode channels: %v", decErr)
|
|
}
|
|
|
|
found := false
|
|
|
|
for _, channel := range channels {
|
|
if channel["name"] == "#listchan" {
|
|
found = true
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
t.Fatal("channel not in list")
|
|
}
|
|
}
|
|
|
|
func TestChannelMembers(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("membertest")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#members",
|
|
})
|
|
|
|
resp, err := doRequestAuth(
|
|
t,
|
|
http.MethodGet,
|
|
tserver.url(
|
|
"/api/v1/channels/members/members",
|
|
),
|
|
token,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestLongPoll(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
aliceToken := tserver.createSession("lp_alice")
|
|
bobToken := tserver.createSession("lp_bob")
|
|
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#longpoll",
|
|
})
|
|
tserver.sendCommand(bobToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#longpoll",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(bobToken, 0)
|
|
|
|
var waitGroup sync.WaitGroup
|
|
|
|
var pollMsgs []map[string]any
|
|
|
|
waitGroup.Add(1)
|
|
|
|
go func() {
|
|
defer waitGroup.Done()
|
|
|
|
pollURL := fmt.Sprintf(
|
|
"%s"+apiMessages+"?timeout=5&after=%d",
|
|
tserver.httpServer.URL, lastID,
|
|
)
|
|
|
|
resp, err := doRequestAuth(
|
|
t, http.MethodGet,
|
|
pollURL, bobToken, nil,
|
|
)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
var result struct {
|
|
Messages []map[string]any `json:"messages"`
|
|
}
|
|
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
|
|
pollMsgs = result.Messages
|
|
}()
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: privmsgCmd,
|
|
toKey: "#longpoll",
|
|
bodyKey: []string{"wake up!"},
|
|
})
|
|
|
|
waitGroup.Wait()
|
|
|
|
if !findMessage(pollMsgs, privmsgCmd, "lp_alice") {
|
|
t.Fatalf(
|
|
"long-poll didn't receive message: %v",
|
|
pollMsgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestLongPollTimeout(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("lp_timeout")
|
|
|
|
start := time.Now()
|
|
|
|
resp, err := doRequestAuth(
|
|
t,
|
|
http.MethodGet,
|
|
tserver.url(apiMessages+"?timeout=1"),
|
|
token,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
elapsed := time.Since(start)
|
|
|
|
if elapsed < 900*time.Millisecond {
|
|
t.Fatalf(
|
|
"long-poll returned too fast: %v", elapsed,
|
|
)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestEphemeralChannelCleanup(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
token := tserver.createSession("ephemeral")
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: joinCmd, toKey: "#ephemeral",
|
|
})
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "PART", toKey: "#ephemeral",
|
|
})
|
|
|
|
resp, err := doRequestAuth(
|
|
t,
|
|
http.MethodGet,
|
|
tserver.url("/api/v1/channels"),
|
|
token,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
var channels []map[string]any
|
|
|
|
decErr := json.NewDecoder(resp.Body).Decode(
|
|
&channels,
|
|
)
|
|
if decErr != nil {
|
|
t.Fatalf("decode channels: %v", decErr)
|
|
}
|
|
|
|
for _, channel := range channels {
|
|
if channel["name"] == "#ephemeral" {
|
|
t.Fatal(
|
|
"ephemeral channel should be cleaned up",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConcurrentSessions(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
var waitGroup sync.WaitGroup
|
|
|
|
const concurrency = 20
|
|
|
|
errs := make(chan error, concurrency)
|
|
|
|
for idx := range concurrency {
|
|
waitGroup.Add(1)
|
|
|
|
go func(index int) {
|
|
defer waitGroup.Done()
|
|
|
|
nick := fmt.Sprintf("conc_%d", index)
|
|
|
|
body, err := json.Marshal(
|
|
map[string]string{"nick": nick},
|
|
)
|
|
if err != nil {
|
|
errs <- fmt.Errorf(
|
|
"marshal: %w", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodPost,
|
|
tserver.url(apiSession),
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
errs <- err
|
|
|
|
return
|
|
}
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
errs <- fmt.Errorf( //nolint:err113
|
|
"status %d for %s",
|
|
resp.StatusCode, nick,
|
|
)
|
|
}
|
|
}(idx)
|
|
}
|
|
|
|
waitGroup.Wait()
|
|
close(errs)
|
|
|
|
for err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("concurrent error: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServerInfo(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodGet,
|
|
tserver.url("/api/v1/server"),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestHealthcheck(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
resp, err := doRequest(
|
|
t,
|
|
http.MethodGet,
|
|
tserver.url("/.well-known/healthcheck.json"),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf(
|
|
"expected 200, got %d", resp.StatusCode,
|
|
)
|
|
}
|
|
|
|
var result map[string]any
|
|
|
|
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
|
if decErr != nil {
|
|
t.Fatalf("decode healthcheck: %v", decErr)
|
|
}
|
|
|
|
if result[statusKey] != "ok" {
|
|
t.Fatalf(
|
|
"expected ok status, got %v",
|
|
result[statusKey],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestNickBroadcastToChannels(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
aliceToken := tserver.createSession("nick_a")
|
|
bobToken := tserver.createSession("nick_b")
|
|
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#nicktest",
|
|
})
|
|
tserver.sendCommand(bobToken, map[string]any{
|
|
commandKey: joinCmd, toKey: "#nicktest",
|
|
})
|
|
|
|
_, lastID := tserver.pollMessages(bobToken, 0)
|
|
|
|
tserver.sendCommand(aliceToken, map[string]any{
|
|
commandKey: "NICK",
|
|
bodyKey: []string{"nick_a_new"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(bobToken, lastID)
|
|
|
|
if !findMessage(msgs, "NICK", "nick_a") {
|
|
t.Fatalf(
|
|
"bob didn't get nick change: %v", msgs,
|
|
)
|
|
}
|
|
}
|