Compare commits
2
Commits
f6f2d2a038
...
436726dc57
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
436726dc57 | ||
|
|
3822cdaf1d |
@@ -1404,7 +1404,8 @@ Include the hashcash stamp in the `meta` field:
|
||||
**Server validation:** The server checks that the stamp is well-formed, meets
|
||||
the required difficulty, is bound to the correct channel and message body, has a
|
||||
recent date, and has not been previously used. Spent stamps are cached for 1
|
||||
year to prevent replay attacks.
|
||||
year to prevent replay attacks. The stamp is checked after membership, bans and
|
||||
`+m`, so a message refused for one of those does not spend it.
|
||||
|
||||
**Error responses:** If the channel requires hashcash and the stamp is missing,
|
||||
invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a
|
||||
@@ -3084,6 +3085,10 @@ limits that punish shared networks.
|
||||
- Stamp has not been used before (replay prevention; the spent stamps are
|
||||
kept in memory, so a restart forgets them)
|
||||
|
||||
The stamp is spent only when the session is created. A request refused with
|
||||
400 or 409 leaves it unspent, and the client can send the same stamp again
|
||||
with another nick or username.
|
||||
|
||||
### Stamp Format
|
||||
|
||||
Standard hashcash format:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.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) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+25
-13
@@ -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 <text>")
|
||||
|
||||
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 <nick> <text> — Send DM",
|
||||
" /query <nick> — Open DM window",
|
||||
" /topic [text] — View/set topic",
|
||||
" /topic <text> — Set topic",
|
||||
" /names — List channel members",
|
||||
" /list — List channels",
|
||||
" /who [#channel] — List users in channel",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func (hdlr *Handlers) handleCreateSession(
|
||||
return
|
||||
}
|
||||
|
||||
if !hdlr.validateHashcash(
|
||||
if !hdlr.checkHashcash(
|
||||
writer, request, payload.Hashcash,
|
||||
) {
|
||||
return
|
||||
@@ -232,14 +232,15 @@ func (hdlr *Handlers) handleCreateSession(
|
||||
}
|
||||
|
||||
hdlr.executeCreateSession(
|
||||
writer, request, payload.Nick, username,
|
||||
writer, request,
|
||||
payload.Nick, username, payload.Hashcash,
|
||||
)
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) executeCreateSession(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
nick, username string,
|
||||
nick, username, stamp string,
|
||||
) {
|
||||
remoteIP := clientIP(request)
|
||||
|
||||
@@ -247,12 +248,17 @@ func (hdlr *Handlers) executeCreateSession(
|
||||
request.Context(), remoteIP,
|
||||
)
|
||||
|
||||
if !hdlr.claimHashcash(writer, request, stamp) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, clientID, token, err :=
|
||||
hdlr.params.Database.CreateSession(
|
||||
request.Context(),
|
||||
nick, username, hostname, remoteIP,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.hashcashVal.Release(stamp)
|
||||
hdlr.handleCreateSessionError(
|
||||
writer, request, err,
|
||||
)
|
||||
@@ -273,10 +279,10 @@ func (hdlr *Handlers) executeCreateSession(
|
||||
}, http.StatusCreated)
|
||||
}
|
||||
|
||||
// validateHashcash validates a hashcash stamp if required.
|
||||
// Returns false if validation failed and a response was
|
||||
// already sent.
|
||||
func (hdlr *Handlers) validateHashcash(
|
||||
// checkHashcash checks a hashcash stamp if required,
|
||||
// without spending it. Returns false if the check failed
|
||||
// and a response was already sent.
|
||||
func (hdlr *Handlers) checkHashcash(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
stamp string,
|
||||
@@ -295,7 +301,31 @@ func (hdlr *Handlers) validateHashcash(
|
||||
return false
|
||||
}
|
||||
|
||||
err := hdlr.hashcashVal.Validate(
|
||||
err := hdlr.hashcashVal.Check(
|
||||
stamp, hdlr.params.Config.HashcashBits,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"invalid hashcash stamp: "+err.Error(),
|
||||
http.StatusPaymentRequired,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// claimHashcash spends a stamp that checkHashcash accepted.
|
||||
// Returns false if Claim refused the stamp, normally because
|
||||
// it was already spent, and a response was already sent.
|
||||
func (hdlr *Handlers) claimHashcash(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
stamp string,
|
||||
) bool {
|
||||
err := hdlr.hashcashVal.Claim(
|
||||
stamp, hdlr.params.Config.HashcashBits,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"modernc.org/sqlite"
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
api "sneak.berlin/go/neoirc/internal/cli/api"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
@@ -4073,6 +4074,114 @@ func TestChannelHashcashMissingBitsArg(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// postSessionWithStamp posts a session request for nick and
|
||||
// username carrying stamp and returns the response status.
|
||||
// An empty username defaults to the nick.
|
||||
func postSessionWithStamp(
|
||||
t *testing.T,
|
||||
tserver *testServer,
|
||||
nick, username, stamp string,
|
||||
) int {
|
||||
t.Helper()
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
nickKey: nick, "username": username, "pow_token": stamp,
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
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
|
||||
// session request is refused with 402 for an invalid stamp
|
||||
// before its nick is looked at, that a request refused with
|
||||
// 400 or 409 does not spend its stamp, so the same stamp
|
||||
// then creates a session, that the stamp stays spent after
|
||||
// that, and that no refused request creates a session.
|
||||
func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
|
||||
const bits = 2
|
||||
|
||||
tserver := newTestServerWith(t, bits, "", "")
|
||||
|
||||
// A server with no SERVER_NAME takes stamps for "neoirc".
|
||||
status := postSessionWithStamp(
|
||||
t, tserver, "taken", "", api.MintHashcash(bits, "neoirc"),
|
||||
)
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("create taken: want 201, got %d", status)
|
||||
}
|
||||
|
||||
stamp := api.MintHashcash(bits, "neoirc")
|
||||
otherServerStamp := api.MintHashcash(bits, "elsewhere")
|
||||
|
||||
steps := []struct {
|
||||
nick, username, stamp string
|
||||
want int
|
||||
}{
|
||||
{"bad nick", "", otherServerStamp, http.StatusPaymentRequired},
|
||||
{"bad nick", "", stamp, http.StatusBadRequest},
|
||||
{"fresh", "bad username", stamp, http.StatusBadRequest},
|
||||
{"taken", "", stamp, http.StatusConflict},
|
||||
{"fresh", "", stamp, http.StatusCreated},
|
||||
{"another", "", stamp, http.StatusPaymentRequired},
|
||||
// Refused as already spent, the stamp is not given back.
|
||||
{"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 {
|
||||
status = postSessionWithStamp(
|
||||
t, tserver, step.nick, step.username, step.stamp,
|
||||
)
|
||||
if status != step.want {
|
||||
t.Fatalf(
|
||||
"nick %q, username %q: want %d, got %d",
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamesShowsHostmask(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
|
||||
@@ -77,9 +77,13 @@ func NewValidator(resource string) *Validator {
|
||||
return validator
|
||||
}
|
||||
|
||||
// Validate checks a hashcash stamp. It returns nil if the
|
||||
// stamp is valid and has not been seen before.
|
||||
func (v *Validator) Validate(
|
||||
// Check returns nil if requiredBits is not positive, or if
|
||||
// the stamp is a well-formed, recently dated proof of work
|
||||
// for this resource with at least requiredBits. It does not
|
||||
// look at or change the set of spent stamps, so a request
|
||||
// can check its stamp first and Claim it after its other
|
||||
// checks.
|
||||
func (v *Validator) Check(
|
||||
stamp string,
|
||||
requiredBits int,
|
||||
) error {
|
||||
@@ -87,9 +91,49 @@ func (v *Validator) Validate(
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := v.check(stamp, requiredBits)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Claim checks the stamp as Check does and, when
|
||||
// requiredBits is positive, records it as spent until it
|
||||
// expires. It returns an error if the stamp is already
|
||||
// spent. Release gives a claimed stamp back.
|
||||
func (v *Validator) Claim(
|
||||
stamp string,
|
||||
requiredBits int,
|
||||
) error {
|
||||
if requiredBits <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stampTime, err := v.check(stamp, requiredBits)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return v.checkAndRecordStamp(stamp, stampTime)
|
||||
}
|
||||
|
||||
// Release forgets a claimed stamp so that it can be claimed
|
||||
// again, for a request that failed after its Claim.
|
||||
// Releasing a stamp that is not claimed does nothing.
|
||||
func (v *Validator) Release(stamp string) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
delete(v.spent, stamp)
|
||||
}
|
||||
|
||||
// check returns the stamp's date if Check accepts it.
|
||||
func (v *Validator) check(
|
||||
stamp string,
|
||||
requiredBits int,
|
||||
) (time.Time, error) {
|
||||
parts := strings.Split(stamp, ":")
|
||||
if len(parts) != stampFields {
|
||||
return fmt.Errorf(
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"%w: expected %d, got %d",
|
||||
errInvalidFields, stampFields, len(parts),
|
||||
)
|
||||
@@ -104,25 +148,25 @@ func (v *Validator) Validate(
|
||||
version, bitsStr, resource, requiredBits,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
stampTime, err := parseStampDate(dateStr)
|
||||
if err != nil {
|
||||
return err
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
err = validateTime(stampTime)
|
||||
if err != nil {
|
||||
return err
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
err = validateProof(stamp, requiredBits)
|
||||
if err != nil {
|
||||
return err
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
return v.checkAndRecordStamp(stamp, stampTime)
|
||||
return stampTime, nil
|
||||
}
|
||||
|
||||
func (v *Validator) validateHeader(
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestMintAndValidate(t *testing.T) {
|
||||
t, testBits, "test-resource", todayDate(),
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
err := validator.Claim(stamp, testBits)
|
||||
if err != nil {
|
||||
t.Fatalf("valid stamp rejected: %v", err)
|
||||
}
|
||||
@@ -99,17 +99,68 @@ func TestReplayDetection(t *testing.T) {
|
||||
t, testBits, "test-resource", todayDate(),
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
err := validator.Claim(stamp, testBits)
|
||||
if err != nil {
|
||||
t.Fatalf("first use failed: %v", err)
|
||||
}
|
||||
|
||||
err = validator.Validate(stamp, testBits)
|
||||
err = validator.Claim(stamp, testBits)
|
||||
if err == nil {
|
||||
t.Fatal("replay not detected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckDoesNotSpendStamp checks that a stamp Check
|
||||
// accepts can still be claimed, and only once.
|
||||
func TestCheckDoesNotSpendStamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, "test-resource", todayDate(),
|
||||
)
|
||||
|
||||
for range 2 {
|
||||
err := validator.Check(stamp, testBits)
|
||||
if err != nil {
|
||||
t.Fatalf("valid stamp rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := validator.Claim(stamp, testBits)
|
||||
if err != nil {
|
||||
t.Fatalf("claim after check failed: %v", err)
|
||||
}
|
||||
|
||||
err = validator.Claim(stamp, testBits)
|
||||
if err == nil {
|
||||
t.Fatal("replay not detected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReleasedStampCanBeClaimedAgain checks that Release
|
||||
// gives a claimed stamp back.
|
||||
func TestReleasedStampCanBeClaimedAgain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
stamp := mintStampWithDate(
|
||||
t, testBits, "test-resource", todayDate(),
|
||||
)
|
||||
|
||||
err := validator.Claim(stamp, testBits)
|
||||
if err != nil {
|
||||
t.Fatalf("first claim failed: %v", err)
|
||||
}
|
||||
|
||||
validator.Release(stamp)
|
||||
|
||||
err = validator.Claim(stamp, testBits)
|
||||
if err != nil {
|
||||
t.Fatalf("claim after release failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -118,7 +169,7 @@ func TestResourceMismatch(t *testing.T) {
|
||||
t, testBits, "wrong-resource", todayDate(),
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
err := validator.Claim(stamp, testBits)
|
||||
if err == nil {
|
||||
t.Fatal("expected resource mismatch error")
|
||||
}
|
||||
@@ -129,7 +180,7 @@ func TestInvalidStampFormat(t *testing.T) {
|
||||
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
|
||||
err := validator.Validate(
|
||||
err := validator.Claim(
|
||||
"not:a:valid:stamp", testBits,
|
||||
)
|
||||
if err == nil {
|
||||
@@ -146,7 +197,7 @@ func TestBadVersion(t *testing.T) {
|
||||
testBits, todayDate(), "test-resource",
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
err := validator.Claim(stamp, testBits)
|
||||
if err == nil {
|
||||
t.Fatal("expected bad version error")
|
||||
}
|
||||
@@ -162,7 +213,7 @@ func TestInsufficientDifficulty(t *testing.T) {
|
||||
todayDate(), "test-resource",
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
err := validator.Claim(stamp, testBits)
|
||||
if err == nil {
|
||||
t.Fatal("expected insufficient bits error")
|
||||
}
|
||||
@@ -178,7 +229,7 @@ func TestExpiredStamp(t *testing.T) {
|
||||
t, testBits, "test-resource", oldDate,
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
err := validator.Claim(stamp, testBits)
|
||||
if err == nil {
|
||||
t.Fatal("expected expired stamp error")
|
||||
}
|
||||
@@ -189,9 +240,14 @@ func TestZeroBitsSkipsValidation(t *testing.T) {
|
||||
|
||||
validator := hashcash.NewValidator("test-resource")
|
||||
|
||||
err := validator.Validate("garbage", 0)
|
||||
err := validator.Check("garbage", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("zero bits should skip: %v", err)
|
||||
t.Fatalf("zero bits should skip the check: %v", err)
|
||||
}
|
||||
|
||||
err = validator.Claim("garbage", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("zero bits should skip the claim: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +260,7 @@ func TestLongDateFormat(t *testing.T) {
|
||||
t, testBits, "test-resource", longDate,
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
err := validator.Claim(stamp, testBits)
|
||||
if err != nil {
|
||||
t.Fatalf("long date stamp rejected: %v", err)
|
||||
}
|
||||
@@ -219,7 +275,7 @@ func TestBadDateFormat(t *testing.T) {
|
||||
testBits, "test-resource",
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
err := validator.Claim(stamp, testBits)
|
||||
if err == nil {
|
||||
t.Fatal("expected bad date error")
|
||||
}
|
||||
@@ -235,7 +291,7 @@ func TestMultipleUniqueStamps(t *testing.T) {
|
||||
t, testBits, "test-resource", todayDate(),
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
err := validator.Claim(stamp, testBits)
|
||||
if err != nil {
|
||||
t.Fatalf("unique stamp rejected: %v", err)
|
||||
}
|
||||
@@ -251,7 +307,7 @@ func TestHigherBitsStillValid(t *testing.T) {
|
||||
t, 4, "test-resource", todayDate(),
|
||||
)
|
||||
|
||||
err := validator.Validate(stamp, testBits)
|
||||
err := validator.Claim(stamp, testBits)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"higher-difficulty stamp rejected: %v",
|
||||
|
||||
+17
-16
@@ -387,13 +387,14 @@ func excludeSession(
|
||||
return out
|
||||
}
|
||||
|
||||
// SendChannelMessage validates the hashcash stamp of a
|
||||
// PRIVMSG to a +H channel, membership and moderation, then
|
||||
// fans out a message to all channel members except the
|
||||
// sender. Returns the database row ID, message UUID, and
|
||||
// any error. The dbID lets callers enqueue the same
|
||||
// message to the sender when echo is needed (HTTP
|
||||
// transport).
|
||||
// SendChannelMessage checks membership, bans and
|
||||
// moderation, then the hashcash stamp of a PRIVMSG to a +H
|
||||
// channel, then fans out a message to all channel members
|
||||
// except the sender. The stamp is checked after the other
|
||||
// checks so that a message they refuse does not spend it.
|
||||
// Returns the database row ID, message UUID, and any error.
|
||||
// The dbID lets callers enqueue the same message to the
|
||||
// sender when echo is needed (HTTP transport).
|
||||
func (s *Service) SendChannelMessage(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
@@ -409,15 +410,6 @@ func (s *Service) SendChannelMessage(
|
||||
return 0, "", noSuchChannel
|
||||
}
|
||||
|
||||
if command == irc.CmdPrivmsg {
|
||||
err = s.checkChannelHashcash(
|
||||
ctx, chID, channel, body, meta,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
}
|
||||
|
||||
isMember, _ := s.db.IsChannelMember(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
@@ -459,6 +451,15 @@ func (s *Service) SendChannelMessage(
|
||||
}
|
||||
}
|
||||
|
||||
if command == irc.CmdPrivmsg {
|
||||
err = s.checkChannelHashcash(
|
||||
ctx, chID, channel, body, meta,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
}
|
||||
|
||||
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||
recipients := excludeSession(memberIDs, sessionID)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
"sneak.berlin/go/neoirc/internal/service"
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
@@ -1056,6 +1057,88 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusedChannelMessageLeavesStampUnspent checks that a
|
||||
// PRIVMSG to a +H channel refused because the sender is not
|
||||
// a member, is banned, or has no voice on a +m channel does
|
||||
// not spend its hashcash stamp: the operator can then send
|
||||
// the same message with it.
|
||||
func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
|
||||
const (
|
||||
channel = "#paid"
|
||||
bits = 2
|
||||
)
|
||||
|
||||
refusals := []struct {
|
||||
name string
|
||||
// refuse stops bob from sending to the channel.
|
||||
refuse func(ctx context.Context, env *testEnv, chID, bobSID int64) error
|
||||
}{
|
||||
{"not a member", func(ctx context.Context, env *testEnv, _, bobSID int64) error {
|
||||
return env.svc.PartChannel(ctx, bobSID, "bob", channel, "")
|
||||
}},
|
||||
{"banned", func(ctx context.Context, env *testEnv, chID, _ int64) error {
|
||||
return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice")
|
||||
}},
|
||||
{"moderated", func(ctx context.Context, env *testEnv, chID, _ int64) error {
|
||||
return env.svc.SetChannelFlag(ctx, chID, 'm', true)
|
||||
}},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
|
||||
|
||||
for _, refusal := range refusals {
|
||||
t.Run(refusal.name, func(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
aliceSID := createSession(ctx, t, env.db, "alice")
|
||||
bobSID := createSession(ctx, t, env.db, "bob")
|
||||
|
||||
joined, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "")
|
||||
if err != nil {
|
||||
t.Fatalf("join alice: %v", err)
|
||||
}
|
||||
|
||||
_, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "")
|
||||
if err != nil {
|
||||
t.Fatalf("join bob: %v", err)
|
||||
}
|
||||
|
||||
err = env.db.SetChannelHashcashBits(ctx, joined.ChannelID, bits)
|
||||
if err != nil {
|
||||
t.Fatalf("set +H: %v", err)
|
||||
}
|
||||
|
||||
err = refusal.refuse(ctx, env, joined.ChannelID, bobSID)
|
||||
if err != nil {
|
||||
t.Fatalf("refuse bob: %v", err)
|
||||
}
|
||||
|
||||
stamp := hashcash.MintChannelStamp(
|
||||
bits, channel, hashcash.BodyHash(body),
|
||||
)
|
||||
meta, _ := json.Marshal(map[string]string{"hashcash": stamp})
|
||||
|
||||
_, _, err = env.svc.SendChannelMessage(
|
||||
ctx, bobSID, "bob", irc.CmdPrivmsg, channel, body, meta,
|
||||
)
|
||||
|
||||
var ircErr *service.IRCError
|
||||
if !errors.As(err, &ircErr) ||
|
||||
ircErr.Code != irc.ErrCannotSendToChan {
|
||||
t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err)
|
||||
}
|
||||
|
||||
_, _, err = env.svc.SendChannelMessage(
|
||||
ctx, aliceSID, "alice", irc.CmdPrivmsg, channel, body, meta,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("alice with the same stamp: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryUserMode(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
Reference in New Issue
Block a user