Files
chat/internal/handlers/api_test.go
clawbot a57a73e94e
All checks were successful
check / check (push) Successful in 2m19s
fix: address all PR #10 review findings
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.
2026-02-26 21:21:49 -08:00

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,
)
}
}