Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f2d2a038 |
@@ -250,7 +250,7 @@ func (client *Client) ListChannels() (
|
|||||||
// GetMembers returns members of a channel.
|
// GetMembers returns members of a channel.
|
||||||
func (client *Client) GetMembers(
|
func (client *Client) GetMembers(
|
||||||
channel string,
|
channel string,
|
||||||
) ([]Member, error) {
|
) ([]string, error) {
|
||||||
name := strings.TrimPrefix(channel, "#")
|
name := strings.TrimPrefix(channel, "#")
|
||||||
|
|
||||||
data, err := client.do(
|
data, err := client.do(
|
||||||
@@ -263,7 +263,7 @@ func (client *Client) GetMembers(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var members []Member
|
var members []string
|
||||||
|
|
||||||
err = json.Unmarshal(data, &members)
|
err = json.Unmarshal(data, &members)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,28 +2,18 @@ package neoircapi_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"reflect"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
api "sneak.berlin/go/neoirc/internal/cli/api"
|
api "sneak.berlin/go/neoirc/internal/cli/api"
|
||||||
"sneak.berlin/go/neoirc/internal/hashcash"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const cookieValue = "opaque-value"
|
||||||
cookieValue = "opaque-value"
|
|
||||||
testNick = "alice"
|
|
||||||
testChannel = "#general"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newSessionServer starts a plain-HTTP server that, like
|
// newSessionServer starts a plain-HTTP server that, like
|
||||||
// neoircd, sets a Secure auth cookie when a session is
|
// neoircd, sets a Secure auth cookie when a session is
|
||||||
@@ -34,16 +24,10 @@ func newSessionServer(t *testing.T) *httptest.Server {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Without hashcash the server sends no hashcash_bits.
|
|
||||||
mux.HandleFunc("GET /api/v1/server", func(
|
mux.HandleFunc("GET /api/v1/server", func(
|
||||||
writer http.ResponseWriter, _ *http.Request,
|
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(
|
mux.HandleFunc("POST /api/v1/session", func(
|
||||||
@@ -72,14 +56,9 @@ func newSessionServer(t *testing.T) *httptest.Server {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = io.WriteString(writer, `{
|
_, _ = io.WriteString(
|
||||||
"id": 1,
|
writer, `{"id":1,"nick":"alice","channels":[]}`,
|
||||||
"nick": "alice",
|
)
|
||||||
"channels": [
|
|
||||||
{"id": 1, "name": "#general", "topic": "Welcome!"},
|
|
||||||
{"id": 2, "name": "#dev", "topic": ""}
|
|
||||||
]
|
|
||||||
}`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
server := httptest.NewServer(mux)
|
server := httptest.NewServer(mux)
|
||||||
@@ -88,344 +67,6 @@ func newSessionServer(t *testing.T) *httptest.Server {
|
|||||||
return 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.Claim(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) {
|
func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -444,7 +85,7 @@ func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
|
|||||||
"http://" + net.JoinHostPort(host, serverURL.Port()),
|
"http://" + net.JoinHostPort(host, serverURL.Port()),
|
||||||
)
|
)
|
||||||
|
|
||||||
_, err := client.CreateSession(testNick)
|
_, err := client.CreateSession("alice")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create session: %v", err)
|
t.Fatalf("create session: %v", err)
|
||||||
}
|
}
|
||||||
@@ -477,7 +118,7 @@ func TestClientWithholdsCookieOverPlainHTTPFromOtherHosts(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := client.CreateSession(testNick)
|
_, err := client.CreateSession("alice")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create session: %v", err)
|
t.Fatalf("create session: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ type SessionResponse struct {
|
|||||||
|
|
||||||
// StateResponse is the response from GET /api/v1/state.
|
// StateResponse is the response from GET /api/v1/state.
|
||||||
type StateResponse struct {
|
type StateResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Channels []Channel `json:"channels"`
|
Channels []string `json:"channels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message represents a neoirc message envelope.
|
// Message represents a neoirc message envelope.
|
||||||
@@ -53,19 +53,12 @@ func (m *Message) BodyLines() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel is a channel as GET /api/v1/channels and
|
// Channel represents a channel in the list response.
|
||||||
// GET /api/v1/state describe it.
|
|
||||||
type Channel struct {
|
type Channel struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
}
|
Members int `json:"members"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
// 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.
|
// ServerInfo is the response from GET /api/v1/server.
|
||||||
@@ -79,7 +72,7 @@ type ServerInfo struct {
|
|||||||
// MessagesResponse wraps polling results.
|
// MessagesResponse wraps polling results.
|
||||||
type MessagesResponse struct {
|
type MessagesResponse struct {
|
||||||
Messages []Message `json:"messages"`
|
Messages []Message `json:"messages"`
|
||||||
LastID int64 `json:"last_id"` //nolint:tagliatelle
|
LastID int64 `json:"lastId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PollResult wraps the poll response including the cursor.
|
// PollResult wraps the poll response including the cursor.
|
||||||
|
|||||||
+13
-25
@@ -434,9 +434,16 @@ func (a *App) cmdTopic(args string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The HTTP API has no topic query: a TOPIC needs text.
|
|
||||||
if args == "" {
|
if args == "" {
|
||||||
a.ui.AddStatus("[red]Usage: /topic <text>")
|
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,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -480,32 +487,12 @@ func (a *App) cmdNames() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
nicks := make([]string, 0, len(members))
|
|
||||||
|
|
||||||
for _, member := range members {
|
|
||||||
nicks = append(nicks, memberPrefix(member)+member.Nick)
|
|
||||||
}
|
|
||||||
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf(
|
a.ui.AddLine(target, fmt.Sprintf(
|
||||||
"[cyan]*** Members of %s: %s",
|
"[cyan]*** Members of %s: %s",
|
||||||
target, strings.Join(nicks, " "),
|
target, strings.Join(members, " "),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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() {
|
func (a *App) cmdList() {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
connected := a.connected
|
connected := a.connected
|
||||||
@@ -530,7 +517,8 @@ func (a *App) cmdList() {
|
|||||||
|
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
" %s %s", ch.Name, ch.Topic,
|
" %s (%d members) %s",
|
||||||
|
ch.Name, ch.Members, ch.Topic,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,7 +676,7 @@ func (a *App) cmdHelp() {
|
|||||||
" /part [#chan] — Leave channel",
|
" /part [#chan] — Leave channel",
|
||||||
" /msg <nick> <text> — Send DM",
|
" /msg <nick> <text> — Send DM",
|
||||||
" /query <nick> — Open DM window",
|
" /query <nick> — Open DM window",
|
||||||
" /topic <text> — Set topic",
|
" /topic [text] — View/set topic",
|
||||||
" /names — List channel members",
|
" /names — List channel members",
|
||||||
" /list — List channels",
|
" /list — List channels",
|
||||||
" /who [#channel] — List users in channel",
|
" /who [#channel] — List users in channel",
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4104,25 +4104,12 @@ func postSessionWithStamp(
|
|||||||
return resp.StatusCode
|
return resp.StatusCode
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionCount returns the number of sessions in tserver's
|
|
||||||
// database.
|
|
||||||
func sessionCount(t *testing.T, tserver *testServer) int64 {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
count, err := tserver.database.GetUserCount(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("count sessions: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRefusedSessionLeavesStampUnspent checks that a
|
// TestRefusedSessionLeavesStampUnspent checks that a
|
||||||
// session request is refused with 402 for an invalid stamp
|
// session request is refused with 402 for an invalid stamp
|
||||||
// before its nick is looked at, that a request refused with
|
// before its nick is looked at, that a request refused with
|
||||||
// 400 or 409 does not spend its stamp, so the same stamp
|
// 400 or 409 does not spend its stamp, so the same stamp
|
||||||
// then creates a session, that the stamp stays spent after
|
// then creates a session, and that the stamp stays spent
|
||||||
// that, and that no refused request creates a session.
|
// after that.
|
||||||
func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
|
func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
|
||||||
const bits = 2
|
const bits = 2
|
||||||
|
|
||||||
@@ -4151,12 +4138,8 @@ func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
|
|||||||
{"another", "", stamp, http.StatusPaymentRequired},
|
{"another", "", stamp, http.StatusPaymentRequired},
|
||||||
// Refused as already spent, the stamp is not given back.
|
// Refused as already spent, the stamp is not given back.
|
||||||
{"later", "", stamp, http.StatusPaymentRequired},
|
{"later", "", stamp, http.StatusPaymentRequired},
|
||||||
// The refused request for "another" left the nick free.
|
|
||||||
{"another", "", api.MintHashcash(bits, "neoirc"), http.StatusCreated},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions := sessionCount(t, tserver)
|
|
||||||
|
|
||||||
for _, step := range steps {
|
for _, step := range steps {
|
||||||
status = postSessionWithStamp(
|
status = postSessionWithStamp(
|
||||||
t, tserver, step.nick, step.username, step.stamp,
|
t, tserver, step.nick, step.username, step.stamp,
|
||||||
@@ -4167,18 +4150,6 @@ func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
|
|||||||
step.nick, step.username, step.want, status,
|
step.nick, step.username, step.want, status,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if status == http.StatusCreated {
|
|
||||||
sessions++
|
|
||||||
}
|
|
||||||
|
|
||||||
count := sessionCount(t, tserver)
|
|
||||||
if count != sessions {
|
|
||||||
t.Fatalf(
|
|
||||||
"nick %q, username %q: want %d sessions, got %d",
|
|
||||||
step.nick, step.username, sessions, count,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ func NewValidator(resource string) *Validator {
|
|||||||
// the stamp is a well-formed, recently dated proof of work
|
// the stamp is a well-formed, recently dated proof of work
|
||||||
// for this resource with at least requiredBits. It does not
|
// for this resource with at least requiredBits. It does not
|
||||||
// look at or change the set of spent stamps, so a request
|
// look at or change the set of spent stamps, so a request
|
||||||
// can check its stamp first and Claim it after its other
|
// can check its stamp first and Claim it once nothing else
|
||||||
// checks.
|
// can refuse the request.
|
||||||
func (v *Validator) Check(
|
func (v *Validator) Check(
|
||||||
stamp string,
|
stamp string,
|
||||||
requiredBits int,
|
requiredBits int,
|
||||||
|
|||||||
@@ -240,14 +240,9 @@ func TestZeroBitsSkipsValidation(t *testing.T) {
|
|||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
|
||||||
err := validator.Check("garbage", 0)
|
err := validator.Claim("garbage", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("zero bits should skip the check: %v", err)
|
t.Fatalf("zero bits should skip: %v", err)
|
||||||
}
|
|
||||||
|
|
||||||
err = validator.Claim("garbage", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("zero bits should skip the claim: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user