Admin endpoints for bans and clients on the single listener (closes #27)
check / check (push) Successful in 4m1s
check / check (push) Successful in 4m1s
SWWAF_ADMIN_TOKEN, or its _FILE form, opens GET and POST /_smallwebwaf/bans, DELETE /_smallwebwaf/bans/<client> and GET /_smallwebwaf/clients/<ip>. Unset, they answer 404; a missing or wrong token gets 401, in observe mode too. They go through every check, as the metrics do. POST takes a netblock, not IPv4-mapped and without a zone, or a client's address, a duration or permanent, and a reason, and makes an admin ban even while another lasts. DELETE lifts every active ban covering the address, kept and marked lifted. Bans come back as bans.json entries; a client as clients.json holds it, with its bans. Judgement call: answers leave out bans.json's version field. Judgement call: DELETE takes an address, not a netblock. Rule suppressed: gosec G304 on a test reading bans.json. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||
)
|
||||
|
||||
const (
|
||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set, and adminBearer
|
||||
// how a request carries it.
|
||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||
adminBearer = "Bearer " + adminSecret
|
||||
// adminClient is the client the tests' admin sends its requests from.
|
||||
adminClient = "192.0.2.10"
|
||||
// banOtherClient is the body of a request to ban otherClient for an
|
||||
// hour.
|
||||
banOtherClient = `{"netblock": "` + otherClient + `", "duration": "1h", ` +
|
||||
`"reason": "probes for logins"}`
|
||||
)
|
||||
|
||||
func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The metrics token is set, and opens none of them.
|
||||
s, clk, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
||||
server.Ledger.BanForLimit(netip.MustParsePrefix(otherClient+"/32"), clk.Now(),
|
||||
bans.Notes{})
|
||||
before := server.Ledger.Snapshot()
|
||||
|
||||
// An empty token does not match the unset one either.
|
||||
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
|
||||
for _, e := range adminEndpoints() {
|
||||
s.adminRequest(adminClient, authorization, e.method, e.path, e.body,
|
||||
http.StatusNotFound, requestlog.ActionAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
if after := server.Ledger.Snapshot(); !slices.Equal(after, before) {
|
||||
t.Errorf("the bans are now\n%+v\nwant them unchanged\n%+v", after, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
// Listing the bans, banning otherClient, lifting that ban, and asking
|
||||
// about otherClient, in that order. Without the admin token, with the
|
||||
// metrics token, or with one that differs, each is refused, and
|
||||
// changes nothing; with the admin token, it is answered.
|
||||
for _, e := range adminEndpoints() {
|
||||
before := server.Ledger.Snapshot()
|
||||
|
||||
for _, authorization := range []string{
|
||||
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
|
||||
} {
|
||||
got := s.adminRequest(adminClient, authorization, e.method, e.path, e.body,
|
||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
||||
if got.header.Get("WWW-Authenticate") != "Bearer" {
|
||||
t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
|
||||
e.method, e.path, authorization)
|
||||
}
|
||||
}
|
||||
|
||||
if after := server.Ledger.Snapshot(); !slices.Equal(after, before) {
|
||||
t.Errorf("%s %s without the token changed the bans to\n%+v\nfrom\n%+v",
|
||||
e.method, e.path, after, before)
|
||||
}
|
||||
|
||||
got := s.admin(e.method, e.path, e.body, http.StatusOK)
|
||||
if got.header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("%s %s answered %q", e.method, e.path, got.header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
// Any other request under /_smallwebwaf/ is not found.
|
||||
for _, e := range []adminEndpoint{
|
||||
{http.MethodPut, proxy.BansPath, banOtherClient},
|
||||
{http.MethodDelete, proxy.BansPath, ""},
|
||||
{http.MethodGet, proxy.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodPost, proxy.ClientsPath + otherClient, ""},
|
||||
{http.MethodGet, strings.TrimSuffix(proxy.ClientsPath, "/"), ""},
|
||||
} {
|
||||
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
banScopeV4Prefix: "24",
|
||||
})
|
||||
|
||||
// A ban on otherClient bans the /24 a ban on that client covers, so it
|
||||
// refuses client too, for an hour.
|
||||
start := clk.Now()
|
||||
expires := start.Add(time.Hour)
|
||||
want := state.BanEntry{
|
||||
Netblock: netip.MustParsePrefix("203.0.113.0/24"),
|
||||
Start: start,
|
||||
Expires: &expires,
|
||||
Cause: bans.CauseAdmin,
|
||||
Reason: "probes for logins",
|
||||
}
|
||||
|
||||
wantBans(t, s.admin(http.MethodPost, proxy.BansPath, banOtherClient, http.StatusOK),
|
||||
want)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
if line.BanExpires != requestlog.FormatTime(expires) {
|
||||
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
|
||||
}
|
||||
|
||||
// Its notes count the request it refused.
|
||||
want.Notes.Requests, want.Notes.Refused = 1, 1
|
||||
|
||||
wantBans(t, s.admin(http.MethodGet, proxy.BansPath, "", http.StatusOK), want)
|
||||
|
||||
// Ten minutes on, lifting the bans on client lifts that one, which is
|
||||
// kept, marked lifted.
|
||||
clk.advance(10 * time.Minute)
|
||||
|
||||
lifted := clk.Now()
|
||||
want.Lifted = &lifted
|
||||
|
||||
wantBans(t, s.admin(http.MethodDelete, proxy.BansPath+"/"+client, "",
|
||||
http.StatusOK), want)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
wantBans(t, s.admin(http.MethodGet, proxy.BansPath, "", http.StatusOK), want)
|
||||
|
||||
// No ban on it is active any more.
|
||||
s.admin(http.MethodDelete, proxy.BansPath+"/"+client, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
banScopeV4Prefix: "24",
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
for _, tc := range []struct {
|
||||
netblock, duration string
|
||||
want string
|
||||
length time.Duration // 0 for a permanent ban
|
||||
}{
|
||||
// An address stands for the netblock a ban on that client covers.
|
||||
{client, "7d", "203.0.113.0/24", 7 * 24 * time.Hour},
|
||||
{"::ffff:198.51.100.7", "90m", "198.51.100.0/24", 90 * time.Minute},
|
||||
{"2001:db8:5::1", "permanent", "2001:db8:5::/64", 0},
|
||||
// A netblock stands for itself, its bits past its length cleared.
|
||||
{"198.51.100.7/16", "1h", "198.51.0.0/16", time.Hour},
|
||||
{"2001:db8:6::/48", "1h", "2001:db8:6::/48", time.Hour},
|
||||
} {
|
||||
// Whitespace may follow the object.
|
||||
body := `{"netblock": "` + tc.netblock + `", "duration": "` + tc.duration + `"}` +
|
||||
"\r\n"
|
||||
want := state.BanEntry{
|
||||
Netblock: netip.MustParsePrefix(tc.want), Start: start, Cause: bans.CauseAdmin,
|
||||
}
|
||||
|
||||
if tc.length != 0 {
|
||||
expires := start.Add(tc.length)
|
||||
want.Expires = &expires
|
||||
}
|
||||
|
||||
wantBans(t, s.admin(http.MethodPost, proxy.BansPath, body, http.StatusOK), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{adminToken: adminSecret})
|
||||
|
||||
for _, tc := range []struct{ body, want string }{
|
||||
{"", "the body is not a JSON object of netblock, duration and reason: EOF"},
|
||||
{"netblock=203.0.113.9", "the body is not a JSON object"},
|
||||
{
|
||||
`{"netblock": "203.0.113.9", "duration": "1h", "until": "2027"}`,
|
||||
`unknown field "until"`,
|
||||
},
|
||||
{
|
||||
`{"netblock": "203.0.113", "duration": "1h"}`,
|
||||
`netblock "203.0.113" is not an address or a netblock`,
|
||||
},
|
||||
// A client's address is looked up as IPv4, so a ban on an
|
||||
// IPv4-mapped netblock would refuse nothing.
|
||||
{
|
||||
`{"netblock": "::ffff:203.0.113.0/120", "duration": "1h"}`,
|
||||
`netblock "::ffff:203.0.113.0/120" is IPv4-mapped`,
|
||||
},
|
||||
// Read as an address, its zone would be "x/48", and its ban on the
|
||||
// /64 around it.
|
||||
{
|
||||
`{"netblock": "2001:db8::1%x/48", "duration": "1h"}`,
|
||||
`netblock "2001:db8::1%x/48" has a zone`,
|
||||
},
|
||||
{
|
||||
`{"netblock": "fe80::1%eth0", "duration": "1h"}`,
|
||||
`netblock "fe80::1%eth0" has a zone`,
|
||||
},
|
||||
// Anything but whitespace after the object.
|
||||
{
|
||||
`{"netblock": "203.0.113.9", "duration": "1h"}` +
|
||||
`{"netblock": "198.51.100.0/24", "duration": "1h"}`,
|
||||
"more follows the object",
|
||||
},
|
||||
{`{"netblock": "203.0.113.9", "duration": "1h"} x`, "more follows the object"},
|
||||
{`{"duration": "1h"}`, `netblock "" is not an address or a netblock`},
|
||||
{`{"netblock": "203.0.113.9"}`, `duration "" is not a duration above zero`},
|
||||
{
|
||||
`{"netblock": "203.0.113.9", "duration": "off"}`,
|
||||
`duration "off" is not a duration above zero`,
|
||||
},
|
||||
{
|
||||
`{"netblock": "203.0.113.9", "duration": "0s"}`,
|
||||
`duration "0s" is not a duration above zero`,
|
||||
},
|
||||
{
|
||||
`{"netblock": "203.0.113.9", "duration": "forever"}`,
|
||||
`duration "forever" is not a duration above zero, such as 1h or 7d, ` +
|
||||
`or permanent`,
|
||||
},
|
||||
// Over the 4 KiB read of a body, even when the object comes first.
|
||||
{
|
||||
`{"netblock": "203.0.113.9", "duration": "1h", "reason": "` +
|
||||
strings.Repeat("x", 4<<10) + `"}`,
|
||||
"request body too large",
|
||||
},
|
||||
{
|
||||
`{"netblock": "203.0.113.9", "duration": "1h"}` + strings.Repeat(" ", 4<<10),
|
||||
"request body too large",
|
||||
},
|
||||
} {
|
||||
got := s.admin(http.MethodPost, proxy.BansPath, tc.body, http.StatusBadRequest)
|
||||
if !strings.Contains(string(got.body), tc.want) {
|
||||
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanToAddOverTheRequestSizeLimitIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
requestMaxBytes: "16",
|
||||
})
|
||||
|
||||
// Sent in a chunk, its length is not announced, so that it is found
|
||||
// over SWWAF_REQUEST_MAX_BYTES only as it is read.
|
||||
chunk := `{"netblock": "203.0.113.9", "duration": "1h"}`
|
||||
s.adminRequest(adminClient, adminBearer+"\r\nTransfer-Encoding: chunked",
|
||||
http.MethodPost, proxy.BansPath,
|
||||
strconv.FormatInt(int64(len(chunk)), 16)+"\r\n"+chunk+"\r\n0\r\n\r\n",
|
||||
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge)
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanToAddSlowerThanTheClientRequestTimeoutIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
metricsToken: token,
|
||||
clientRequestTimeout: shortTimeoutSetting,
|
||||
})
|
||||
|
||||
// The chunk announces 256 bytes and the rest of it never comes, so only
|
||||
// the timeout ends the wait. A hold-up of the test process can only
|
||||
// make the answer later, so the time is checked only for not being
|
||||
// shorter than the timeout.
|
||||
start := time.Now()
|
||||
|
||||
s.adminRequest(adminClient, adminBearer+"\r\nTransfer-Encoding: chunked",
|
||||
http.MethodPost, proxy.BansPath, "100\r\n"+`{"netblock": "203.0.113.9", `,
|
||||
http.StatusRequestTimeout, requestlog.ActionTimedOut)
|
||||
|
||||
if took := time.Since(start); took < shortTimeout {
|
||||
t.Errorf("answered after %s, before the timeout of %s ran out", took, shortTimeout)
|
||||
}
|
||||
|
||||
wantLimitHits(t, s.addr, clientRequestTimeout, 1)
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
rateLimitPerMinute: "2",
|
||||
rateLimitExemptNets: adminClient,
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
// Two of otherClient's requests are let through; the third breaks the
|
||||
// limit of two a minute, and bans it.
|
||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
// Asked about by its address in IPv6 form too.
|
||||
for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
|
||||
var got struct {
|
||||
Client *ratelimit.Client `json:"client"`
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}
|
||||
|
||||
decode(t, s.admin(http.MethodGet, proxy.ClientsPath+addr, "", http.StatusOK), &got)
|
||||
|
||||
if got.Client == nil {
|
||||
t.Fatalf("%s: no client", addr)
|
||||
}
|
||||
|
||||
history := got.Client.History
|
||||
if got.Client.Client != netip.MustParsePrefix(otherClient+"/32") ||
|
||||
history.Requests != 3 || history.Forwarded != 2 || history.Refused != 1 ||
|
||||
history.Offences.Limit != 1 || !history.FirstSeen.Equal(start) {
|
||||
t.Errorf("%s: client %+v", addr, got.Client)
|
||||
}
|
||||
|
||||
if len(got.Bans) != 1 || got.Bans[0].Cause != bans.CauseLimit ||
|
||||
got.Bans[0].Reason != "requests per minute over the limit of 2" ||
|
||||
got.Bans[0].Notes.Count != 3 {
|
||||
t.Errorf("%s: bans %+v, want the one for the broken limit", addr, got.Bans)
|
||||
}
|
||||
}
|
||||
|
||||
// Of an address no request came from and no ban covers, nothing is
|
||||
// known.
|
||||
got := s.admin(http.MethodGet, proxy.ClientsPath+"198.51.100.99", "", http.StatusOK)
|
||||
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
|
||||
t.Errorf("an unknown client is answered\n%s", got.body)
|
||||
}
|
||||
|
||||
s.admin(http.MethodGet, proxy.ClientsPath+"203.0.113", "", http.StatusBadRequest)
|
||||
s.admin(http.MethodDelete, proxy.BansPath+"/203.0.113.0/24", "",
|
||||
http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestBannedClientIsRefusedAtTheEndpointsEvenWithTheToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{adminToken: adminSecret})
|
||||
|
||||
s.admin(http.MethodPost, proxy.BansPath, banOtherClient, http.StatusOK)
|
||||
|
||||
// otherClient cannot lift its own ban either.
|
||||
for _, e := range adminEndpoints() {
|
||||
s.adminRequest(otherClient, adminBearer, e.method, e.path, e.body,
|
||||
http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRequestsCountTowardTheLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
rateLimitPerMinute: "2",
|
||||
})
|
||||
|
||||
// A request refused for a missing token and one answered count toward
|
||||
// the limit of two a minute, so the next breaks it.
|
||||
s.adminRequest(client, "", http.MethodGet, proxy.BansPath, "",
|
||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
||||
s.adminRequest(client, adminBearer, http.MethodGet, proxy.BansPath, "",
|
||||
http.StatusOK, requestlog.ActionAdmin)
|
||||
s.adminRequest(client, adminBearer, http.MethodGet, proxy.BansPath, "",
|
||||
http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
}
|
||||
|
||||
func TestClientInAllowNetsSkipsTheChecksButNeedsTheToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
||||
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
allowNets: allowed,
|
||||
rateLimitPerMinute: "1",
|
||||
})
|
||||
|
||||
// A ban on it refuses nothing, and its requests are not counted.
|
||||
server.Ledger.BanForAdmin(netip.MustParsePrefix(allowed+"/32"), clk.Now(),
|
||||
time.Time{}, "")
|
||||
|
||||
for range 2 {
|
||||
s.adminRequest(allowed, "", http.MethodGet, proxy.BansPath, "",
|
||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
||||
s.adminRequest(allowed, adminBearer, http.MethodGet, proxy.BansPath, "",
|
||||
http.StatusOK, requestlog.ActionAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
mode: observe,
|
||||
})
|
||||
|
||||
for _, e := range adminEndpoints() {
|
||||
s.adminRequest(adminClient, "", e.method, e.path, e.body,
|
||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
||||
}
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||
}
|
||||
}
|
||||
|
||||
// adminEndpoint is a request to an endpoint SWWAF_ADMIN_TOKEN opens.
|
||||
type adminEndpoint struct {
|
||||
method, path, body string
|
||||
}
|
||||
|
||||
// adminEndpoints returns a request to each endpoint SWWAF_ADMIN_TOKEN
|
||||
// opens: listing the bans, banning otherClient for an hour, lifting the
|
||||
// bans on otherClient, and asking about otherClient.
|
||||
func adminEndpoints() []adminEndpoint {
|
||||
return []adminEndpoint{
|
||||
{http.MethodGet, proxy.BansPath, ""},
|
||||
{http.MethodPost, proxy.BansPath, banOtherClient},
|
||||
{http.MethodDelete, proxy.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodGet, proxy.ClientsPath + otherClient, ""},
|
||||
}
|
||||
}
|
||||
|
||||
// admin sends a request with method for path, with body, from
|
||||
// adminClient, with the admin token, and checks that it is answered with
|
||||
// status, its log line's action admin. It returns the answer.
|
||||
func (s *sender) admin(method, path, body string, status int) answer {
|
||||
s.t.Helper()
|
||||
|
||||
return s.adminRequest(adminClient, adminBearer, method, path, body, status,
|
||||
requestlog.ActionAdmin)
|
||||
}
|
||||
|
||||
// adminRequest sends a request with method for path, with body, from the
|
||||
// client at from, with authorization as its Authorization header unless
|
||||
// it is "", and checks its answer's status and its log line's action, as
|
||||
// request does. authorization may end in more header lines. A body that
|
||||
// is not "" has its length announced, unless authorization names
|
||||
// Transfer-Encoding. It returns the answer.
|
||||
func (s *sender) adminRequest(
|
||||
from, authorization, method, path, body string, status int, action string,
|
||||
) answer {
|
||||
s.t.Helper()
|
||||
|
||||
var header []string
|
||||
|
||||
if authorization != "" {
|
||||
header = append(header, "Authorization: "+authorization)
|
||||
}
|
||||
|
||||
if body != "" && !strings.Contains(authorization, "Transfer-Encoding") {
|
||||
header = append(header, "Content-Length: "+strconv.Itoa(len(body)))
|
||||
}
|
||||
|
||||
_, got := s.requestWithBody(method, from, path, strings.Join(header, "\r\n"),
|
||||
body, status, action)
|
||||
|
||||
return got
|
||||
}
|
||||
|
||||
// wantBans checks that a ban endpoint answered with want, and no other
|
||||
// ban.
|
||||
func wantBans(t *testing.T, got answer, want ...state.BanEntry) {
|
||||
t.Helper()
|
||||
|
||||
var decoded struct {
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}
|
||||
|
||||
decode(t, got, &decoded)
|
||||
|
||||
gotJSON, err := json.Marshal(decoded.Bans)
|
||||
if err != nil {
|
||||
t.Fatalf("encode %+v: %v", decoded.Bans, err)
|
||||
}
|
||||
|
||||
wantJSON, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatalf("encode %+v: %v", want, err)
|
||||
}
|
||||
|
||||
if string(gotJSON) != string(wantJSON) {
|
||||
t.Errorf("bans\n%s\nwant\n%s", gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// decode reads the JSON answer of an endpoint into value.
|
||||
func decode(t *testing.T, got answer, value any) {
|
||||
t.Helper()
|
||||
|
||||
err := json.Unmarshal(got.body, value)
|
||||
if err != nil {
|
||||
t.Fatalf("decode %s: %v", got.body, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user