check / check (push) Waiting to run
The CLI read the poll cursor from `lastId` while the server sends `last_id`, so the cursor stayed at 0 and every poll returned the whole queue at once. It now reads `last_id`. /names decodes the member objects the server sends and prints each nick with `@` for an operator or `+` for a voiced member. /list prints each channel's name and topic, since the server sends no member count. StateResponse.Channels decodes channel objects. /topic with no text prints a usage line and sends nothing, because the HTTP API has no topic query and a TOPIC without text gets 461. The help says "Set topic". The client tests serve the bodies the handlers send, so a wrong JSON tag in types.go fails a test; another test covers the /names prefixes. Model: opus-5-5
491 lines
11 KiB
Go
491 lines
11 KiB
Go
package neoircapi_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
api "sneak.berlin/go/neoirc/internal/cli/api"
|
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
|
)
|
|
|
|
const (
|
|
cookieValue = "opaque-value"
|
|
testNick = "alice"
|
|
testChannel = "#general"
|
|
)
|
|
|
|
// newSessionServer starts a plain-HTTP server that, like
|
|
// neoircd, sets a Secure auth cookie when a session is
|
|
// created and answers GET /api/v1/state only when that
|
|
// cookie comes back.
|
|
func newSessionServer(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// Without hashcash the server sends no hashcash_bits.
|
|
mux.HandleFunc("GET /api/v1/server", func(
|
|
writer http.ResponseWriter, _ *http.Request,
|
|
) {
|
|
_, _ = io.WriteString(writer, `{
|
|
"name": "My NeoIRC Server",
|
|
"version": "0.1.0",
|
|
"motd": "Welcome! Be nice.",
|
|
"users": 42
|
|
}`)
|
|
})
|
|
|
|
mux.HandleFunc("POST /api/v1/session", func(
|
|
writer http.ResponseWriter, _ *http.Request,
|
|
) {
|
|
http.SetCookie(writer, &http.Cookie{
|
|
Name: "neoirc_auth",
|
|
Value: cookieValue,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
writer.WriteHeader(http.StatusCreated)
|
|
|
|
_, _ = io.WriteString(writer, `{"id":1,"nick":"alice"}`)
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/v1/state", func(
|
|
writer http.ResponseWriter, request *http.Request,
|
|
) {
|
|
cookie, err := request.Cookie("neoirc_auth")
|
|
if err != nil || cookie.Value != cookieValue {
|
|
writer.WriteHeader(http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
_, _ = io.WriteString(writer, `{
|
|
"id": 1,
|
|
"nick": "alice",
|
|
"channels": [
|
|
{"id": 1, "name": "#general", "topic": "Welcome!"},
|
|
{"id": 2, "name": "#dev", "topic": ""}
|
|
]
|
|
}`)
|
|
})
|
|
|
|
server := httptest.NewServer(mux)
|
|
t.Cleanup(server.Close)
|
|
|
|
return server
|
|
}
|
|
|
|
// newClientForPath starts a server that answers GET path with
|
|
// body, and returns a client for it.
|
|
func newClientForPath(t *testing.T, path, body string) *api.Client {
|
|
t.Helper()
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("GET "+path, func(
|
|
writer http.ResponseWriter, _ *http.Request,
|
|
) {
|
|
_, _ = io.WriteString(writer, body)
|
|
})
|
|
|
|
server := httptest.NewServer(mux)
|
|
t.Cleanup(server.Close)
|
|
|
|
return api.NewClient(server.URL)
|
|
}
|
|
|
|
func TestGetStateDecodesFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := api.NewClient(newSessionServer(t).URL)
|
|
|
|
_, err := client.CreateSession(testNick)
|
|
if err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
|
|
state, err := client.GetState()
|
|
if err != nil {
|
|
t.Fatalf("state: %v", err)
|
|
}
|
|
|
|
want := api.StateResponse{
|
|
ID: 1,
|
|
Nick: testNick,
|
|
Channels: []api.Channel{
|
|
{Name: testChannel, Topic: "Welcome!"},
|
|
{Name: "#dev", Topic: ""},
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(*state, want) {
|
|
t.Fatalf("state: got %+v, want %+v", *state, want)
|
|
}
|
|
}
|
|
|
|
func TestGetServerInfoDecodesFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := newClientForPath(t, "/api/v1/server", `{
|
|
"name": "My NeoIRC Server",
|
|
"version": "0.1.0",
|
|
"motd": "Welcome! Be nice.",
|
|
"users": 42,
|
|
"hashcash_bits": 20
|
|
}`)
|
|
|
|
info, err := client.GetServerInfo()
|
|
if err != nil {
|
|
t.Fatalf("server info: %v", err)
|
|
}
|
|
|
|
want := api.ServerInfo{
|
|
Name: "My NeoIRC Server",
|
|
MOTD: "Welcome! Be nice.",
|
|
Version: "0.1.0",
|
|
HashcashBits: 20,
|
|
}
|
|
if *info != want {
|
|
t.Fatalf("server info: got %+v, want %+v", *info, want)
|
|
}
|
|
}
|
|
|
|
// TestCreateSessionSendsNickAndStamp serves a server that, like
|
|
// neoircd with hashcash on, advertises hashcash_bits and creates
|
|
// the session only for a pow_token the server's validator in
|
|
// internal/hashcash accepts.
|
|
func TestCreateSessionSendsNickAndStamp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
serverName = "My NeoIRC Server"
|
|
stampBits = 8
|
|
sessionID = 7
|
|
)
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("GET /api/v1/server", func(
|
|
writer http.ResponseWriter, _ *http.Request,
|
|
) {
|
|
_, _ = fmt.Fprintf(writer, `{
|
|
"name": %q,
|
|
"version": "0.1.0",
|
|
"motd": "Welcome! Be nice.",
|
|
"users": 42,
|
|
"hashcash_bits": %d
|
|
}`, serverName, stampBits)
|
|
})
|
|
|
|
validator := hashcash.NewValidator(serverName)
|
|
|
|
mux.HandleFunc("POST /api/v1/session", func(
|
|
writer http.ResponseWriter, request *http.Request,
|
|
) {
|
|
// Decoded with the field names handleCreateSession uses.
|
|
var payload struct {
|
|
Nick string `json:"nick"`
|
|
Hashcash string `json:"pow_token"` //nolint:tagliatelle
|
|
}
|
|
|
|
err := json.NewDecoder(request.Body).Decode(&payload)
|
|
if err != nil {
|
|
writer.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
err = validator.Validate(payload.Hashcash, stampBits)
|
|
if err != nil {
|
|
writer.WriteHeader(http.StatusPaymentRequired)
|
|
|
|
return
|
|
}
|
|
|
|
writer.WriteHeader(http.StatusCreated)
|
|
|
|
_ = json.NewEncoder(writer).Encode(map[string]any{
|
|
"id": sessionID,
|
|
"nick": payload.Nick,
|
|
})
|
|
})
|
|
|
|
server := httptest.NewServer(mux)
|
|
t.Cleanup(server.Close)
|
|
|
|
session, err := api.NewClient(server.URL).CreateSession(testNick)
|
|
if err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
|
|
want := api.SessionResponse{ID: sessionID, Nick: testNick}
|
|
if *session != want {
|
|
t.Fatalf("session: got %+v, want %+v", *session, want)
|
|
}
|
|
}
|
|
|
|
func TestListChannelsDecodesNamesAndTopics(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := newClientForPath(t, "/api/v1/channels", `[
|
|
{"id": 1, "name": "#general", "topic": "Welcome!"},
|
|
{"id": 2, "name": "#dev", "topic": "Development discussion"}
|
|
]`)
|
|
|
|
channels, err := client.ListChannels()
|
|
if err != nil {
|
|
t.Fatalf("list channels: %v", err)
|
|
}
|
|
|
|
want := []api.Channel{
|
|
{Name: testChannel, Topic: "Welcome!"},
|
|
{Name: "#dev", Topic: "Development discussion"},
|
|
}
|
|
if !slices.Equal(channels, want) {
|
|
t.Fatalf("channels: got %+v, want %+v", channels, want)
|
|
}
|
|
}
|
|
|
|
func TestGetMembersDecodesMemberObjects(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := newClientForPath(t, "/api/v1/channels/general/members", `[
|
|
{
|
|
"id": 1, "nick": "alice", "username": "alice",
|
|
"hostname": "host.example.com",
|
|
"isOperator": true, "isVoiced": false,
|
|
"lastSeen": "2026-02-10T20:00:00Z"
|
|
},
|
|
{
|
|
"id": 2, "nick": "bob", "username": "bob",
|
|
"hostname": "host.example.com",
|
|
"isOperator": false, "isVoiced": true,
|
|
"lastSeen": "2026-02-10T20:00:00Z"
|
|
},
|
|
{
|
|
"id": 3, "nick": "Carol", "username": "carol",
|
|
"hostname": "host.example.com",
|
|
"isOperator": false, "isVoiced": false,
|
|
"lastSeen": "2026-02-10T20:00:00Z"
|
|
}
|
|
]`)
|
|
|
|
members, err := client.GetMembers(testChannel)
|
|
if err != nil {
|
|
t.Fatalf("get members: %v", err)
|
|
}
|
|
|
|
want := []api.Member{
|
|
{Nick: testNick, IsOperator: true, IsVoiced: false},
|
|
{Nick: "bob", IsOperator: false, IsVoiced: true},
|
|
{Nick: "Carol", IsOperator: false, IsVoiced: false},
|
|
}
|
|
if !slices.Equal(members, want) {
|
|
t.Fatalf("members: got %+v, want %+v", members, want)
|
|
}
|
|
}
|
|
|
|
// TestPollMessagesAdvancesCursor serves the queue as the server
|
|
// does: entries after the cursor, and last_id as the new cursor.
|
|
func TestPollMessagesAdvancesCursor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const lastQueueID = 42
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("GET /api/v1/messages", func(
|
|
writer http.ResponseWriter, request *http.Request,
|
|
) {
|
|
after := request.URL.Query().Get("after")
|
|
if after == strconv.Itoa(lastQueueID) {
|
|
_, _ = io.WriteString(
|
|
writer, `{"messages": [], "last_id": 42}`,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
_, _ = io.WriteString(writer, `{
|
|
"messages": [{
|
|
"id": "b7c8210f-849c-4b90-9ee8-d99c8889358e",
|
|
"command": "PRIVMSG",
|
|
"from": "alice",
|
|
"to": "#general",
|
|
"body": ["hello world"],
|
|
"ts": "2026-02-10T20:00:01.234567891Z",
|
|
"meta": {}
|
|
}],
|
|
"last_id": 42
|
|
}`)
|
|
})
|
|
|
|
server := httptest.NewServer(mux)
|
|
t.Cleanup(server.Close)
|
|
|
|
client := api.NewClient(server.URL)
|
|
|
|
first, err := client.PollMessages(0, 0)
|
|
if err != nil {
|
|
t.Fatalf("first poll: %v", err)
|
|
}
|
|
|
|
if first.LastID != lastQueueID || len(first.Messages) != 1 {
|
|
t.Fatalf(
|
|
"first poll: got last ID %d and %d messages, "+
|
|
"want %d and 1",
|
|
first.LastID, len(first.Messages), lastQueueID,
|
|
)
|
|
}
|
|
|
|
second, err := client.PollMessages(first.LastID, 0)
|
|
if err != nil {
|
|
t.Fatalf("second poll: %v", err)
|
|
}
|
|
|
|
if len(second.Messages) != 0 {
|
|
t.Fatalf(
|
|
"second poll: got %d messages again, want none",
|
|
len(second.Messages),
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestPollMessagesDecodesMessageFields serves a relayed PRIVMSG
|
|
// with the sender's meta and a numeric, as the server queues
|
|
// them.
|
|
func TestPollMessagesDecodesMessageFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := newClientForPath(t, "/api/v1/messages", `{
|
|
"messages": [
|
|
{
|
|
"id": "b7c8210f-849c-4b90-9ee8-d99c8889358e",
|
|
"command": "PRIVMSG",
|
|
"from": "alice",
|
|
"to": "#general",
|
|
"body": ["signed message"],
|
|
"ts": "2026-02-10T20:00:01.234567891Z",
|
|
"meta": {"sig": "base64...", "alg": "ed25519"}
|
|
},
|
|
{
|
|
"id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
|
|
"command": "RPL_LUSEROP",
|
|
"code": 252,
|
|
"from": "neoirc",
|
|
"to": "alice",
|
|
"params": ["0"],
|
|
"body": ["operator(s) online"],
|
|
"ts": "2026-02-10T20:00:02Z",
|
|
"meta": {}
|
|
}
|
|
],
|
|
"last_id": 42
|
|
}`)
|
|
|
|
result, err := client.PollMessages(0, 0)
|
|
if err != nil {
|
|
t.Fatalf("poll: %v", err)
|
|
}
|
|
|
|
want := []api.Message{
|
|
{
|
|
ID: "b7c8210f-849c-4b90-9ee8-d99c8889358e",
|
|
Command: "PRIVMSG",
|
|
From: testNick,
|
|
To: testChannel,
|
|
Body: []any{"signed message"},
|
|
TS: "2026-02-10T20:00:01.234567891Z",
|
|
Meta: map[string]any{"sig": "base64...", "alg": "ed25519"},
|
|
},
|
|
{
|
|
ID: "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
|
|
Command: "RPL_LUSEROP",
|
|
From: "neoirc",
|
|
To: testNick,
|
|
Params: []string{"0"},
|
|
Body: []any{"operator(s) online"},
|
|
TS: "2026-02-10T20:00:02Z",
|
|
Meta: map[string]any{},
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(result.Messages, want) {
|
|
t.Fatalf("messages: got %+v, want %+v", result.Messages, want)
|
|
}
|
|
}
|
|
|
|
func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := newSessionServer(t)
|
|
|
|
serverURL, err := url.Parse(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("parse server URL: %v", err)
|
|
}
|
|
|
|
for _, host := range []string{"127.0.0.1", "localhost"} {
|
|
t.Run(host, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := api.NewClient(
|
|
"http://" + net.JoinHostPort(host, serverURL.Port()),
|
|
)
|
|
|
|
_, err := client.CreateSession(testNick)
|
|
if err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
|
|
_, err = client.GetState()
|
|
if err != nil {
|
|
t.Fatalf("state after creating the session: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClientWithholdsCookieOverPlainHTTPFromOtherHosts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := newSessionServer(t)
|
|
|
|
// neoirc.example is not loopback. Every connection the client
|
|
// opens to it goes to the test server instead.
|
|
client := api.NewClient("http://neoirc.example")
|
|
client.HTTPClient.Transport = &http.Transport{
|
|
DialContext: func(
|
|
ctx context.Context, network, _ string,
|
|
) (net.Conn, error) {
|
|
var dialer net.Dialer
|
|
|
|
return dialer.DialContext(
|
|
ctx, network, server.Listener.Addr().String(),
|
|
)
|
|
},
|
|
}
|
|
|
|
_, err := client.CreateSession(testNick)
|
|
if err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
|
|
// The server answers 401 when the auth cookie does not arrive.
|
|
_, err = client.GetState()
|
|
if err == nil || !strings.Contains(err.Error(), "401") {
|
|
t.Fatalf("state: got %v, want 401: cookie sent over plain HTTP", err)
|
|
}
|
|
}
|