diff --git a/internal/cli/api/client.go b/internal/cli/api/client.go index 5dd5972..91222a8 100644 --- a/internal/cli/api/client.go +++ b/internal/cli/api/client.go @@ -250,7 +250,7 @@ func (client *Client) ListChannels() ( // GetMembers returns members of a channel. func (client *Client) GetMembers( channel string, -) ([]string, error) { +) ([]Member, error) { name := strings.TrimPrefix(channel, "#") data, err := client.do( @@ -263,7 +263,7 @@ func (client *Client) GetMembers( return nil, err } - var members []string + var members []Member err = json.Unmarshal(data, &members) if err != nil { diff --git a/internal/cli/api/client_test.go b/internal/cli/api/client_test.go index 8a42cd1..7a5149c 100644 --- a/internal/cli/api/client_test.go +++ b/internal/cli/api/client_test.go @@ -2,18 +2,28 @@ 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" +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 @@ -24,10 +34,16 @@ func newSessionServer(t *testing.T) *httptest.Server { 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, `{}`) + _, _ = io.WriteString(writer, `{ + "name": "My NeoIRC Server", + "version": "0.1.0", + "motd": "Welcome! Be nice.", + "users": 42 + }`) }) mux.HandleFunc("POST /api/v1/session", func( @@ -56,9 +72,14 @@ func newSessionServer(t *testing.T) *httptest.Server { return } - _, _ = io.WriteString( - writer, `{"id":1,"nick":"alice","channels":[]}`, - ) + _, _ = io.WriteString(writer, `{ + "id": 1, + "nick": "alice", + "channels": [ + {"id": 1, "name": "#general", "topic": "Welcome!"}, + {"id": 2, "name": "#dev", "topic": ""} + ] + }`) }) server := httptest.NewServer(mux) @@ -67,6 +88,344 @@ func newSessionServer(t *testing.T) *httptest.Server { 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() @@ -85,7 +444,7 @@ func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) { "http://" + net.JoinHostPort(host, serverURL.Port()), ) - _, err := client.CreateSession("alice") + _, err := client.CreateSession(testNick) if err != nil { t.Fatalf("create session: %v", err) } @@ -118,7 +477,7 @@ func TestClientWithholdsCookieOverPlainHTTPFromOtherHosts(t *testing.T) { }, } - _, err := client.CreateSession("alice") + _, err := client.CreateSession(testNick) if err != nil { t.Fatalf("create session: %v", err) } diff --git a/internal/cli/api/types.go b/internal/cli/api/types.go index 8c7e50c..e785020 100644 --- a/internal/cli/api/types.go +++ b/internal/cli/api/types.go @@ -16,9 +16,9 @@ type SessionResponse struct { // StateResponse is the response from GET /api/v1/state. type StateResponse struct { - ID int64 `json:"id"` - Nick string `json:"nick"` - Channels []string `json:"channels"` + ID int64 `json:"id"` + Nick string `json:"nick"` + Channels []Channel `json:"channels"` } // Message represents a neoirc message envelope. @@ -53,12 +53,19 @@ func (m *Message) BodyLines() []string { } } -// Channel represents a channel in the list response. +// Channel is a channel as GET /api/v1/channels and +// GET /api/v1/state describe it. type Channel struct { - Name string `json:"name"` - Topic string `json:"topic"` - Members int `json:"members"` - CreatedAt string `json:"createdAt"` + Name string `json:"name"` + Topic string `json:"topic"` +} + +// Member is one entry of the response from +// GET /api/v1/channels/{name}/members. +type Member struct { + Nick string `json:"nick"` + IsOperator bool `json:"isOperator"` + IsVoiced bool `json:"isVoiced"` } // ServerInfo is the response from GET /api/v1/server. @@ -72,7 +79,7 @@ type ServerInfo struct { // MessagesResponse wraps polling results. type MessagesResponse struct { Messages []Message `json:"messages"` - LastID int64 `json:"lastId"` + LastID int64 `json:"last_id"` //nolint:tagliatelle } // PollResult wraps the poll response including the cursor. diff --git a/internal/cli/app.go b/internal/cli/app.go index ea53b15..85df5c8 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -434,16 +434,9 @@ func (a *App) cmdTopic(args string) { return } + // The HTTP API has no topic query: a TOPIC needs text. if args == "" { - err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: irc.CmdTopic, - To: target, - }) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Topic query failed: %v", err, - )) - } + a.ui.AddStatus("[red]Usage: /topic ") return } @@ -487,12 +480,32 @@ func (a *App) cmdNames() { return } + nicks := make([]string, 0, len(members)) + + for _, member := range members { + nicks = append(nicks, memberPrefix(member)+member.Nick) + } + a.ui.AddLine(target, fmt.Sprintf( "[cyan]*** Members of %s: %s", - target, strings.Join(members, " "), + target, strings.Join(nicks, " "), )) } +// memberPrefix returns "@" for an operator, "+" for a voiced +// member, and "" for anyone else. +func memberPrefix(member api.Member) string { + if member.IsOperator { + return "@" + } + + if member.IsVoiced { + return "+" + } + + return "" +} + func (a *App) cmdList() { a.mu.Lock() connected := a.connected @@ -517,8 +530,7 @@ func (a *App) cmdList() { for _, ch := range channels { a.ui.AddStatus(fmt.Sprintf( - " %s (%d members) %s", - ch.Name, ch.Members, ch.Topic, + " %s %s", ch.Name, ch.Topic, )) } @@ -676,7 +688,7 @@ func (a *App) cmdHelp() { " /part [#chan] — Leave channel", " /msg — Send DM", " /query — Open DM window", - " /topic [text] — View/set topic", + " /topic — Set topic", " /names — List channel members", " /list — List channels", " /who [#channel] — List users in channel", diff --git a/internal/cli/app_internal_test.go b/internal/cli/app_internal_test.go new file mode 100644 index 0000000..985a493 --- /dev/null +++ b/internal/cli/app_internal_test.go @@ -0,0 +1,51 @@ +package cli + +import ( + "testing" + + api "sneak.berlin/go/neoirc/internal/cli/api" +) + +func TestMemberPrefix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + member api.Member + want string + }{ + { + name: "operator", + member: api.Member{Nick: "alice", IsOperator: true}, + want: "@", + }, + { + name: "voiced member", + member: api.Member{Nick: "bob", IsVoiced: true}, + want: "+", + }, + { + name: "voiced operator", + member: api.Member{ + Nick: "Carol", IsOperator: true, IsVoiced: true, + }, + want: "@", + }, + { + name: "plain member", + member: api.Member{Nick: "dave"}, + want: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got := memberPrefix(test.member) + if got != test.want { + t.Fatalf("got %q, want %q", got, test.want) + } + }) + } +}