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