Files
ntfy/server/server_admin_test.go
binwiederhier 2e8506f827 Make the admin socket experimental and off by default
Rename listen-admin-unix to experimental-listen-admin-unix and drop the
/run/ntfy/admin.sock default: the CLI side (#2089, #2135) is not merged,
so nothing uses the socket yet. The startup warning no longer points at a
CLI fallback, and the docs say the commands do not use it yet.
2026-10-31 15:00:21 +00:00

1662 lines
64 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/ban"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
func TestVersion_Admin(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
c := newTestConfigWithAuthFile(t, databaseURL)
c.BuildVersion = "1.2.3"
c.BuildCommit = "abcdef0"
c.BuildDate = "2026-02-08T00:00:00Z"
s := newTestServer(t, c)
defer s.closeDatabases()
// Create admin and regular user
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Admin can access /v1/version
rr := request(t, s, "GET", "/v1/version", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
var versionResponse apiVersionResponse
require.Nil(t, json.NewDecoder(rr.Body).Decode(&versionResponse))
require.Equal(t, "1.2.3", versionResponse.Version)
require.Equal(t, "abcdef0", versionResponse.Commit)
require.Equal(t, "2026-02-08T00:00:00Z", versionResponse.Date)
// Non-admin user cannot access /v1/version
rr = request(t, s, "GET", "/v1/version", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
// Unauthenticated user cannot access /v1/version
rr = request(t, s, "GET", "/v1/version", "", nil)
require.Equal(t, 401, rr.Code)
})
}
func TestUser_AddRemove(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin, tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier1",
}))
// Create user via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Create user with tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "tier1"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 4, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
require.Nil(t, users[1].Tier)
require.Equal(t, "emma", users[2].Name)
require.Equal(t, user.RoleUser, users[2].Role)
require.Equal(t, "tier1", users[2].Tier.Code)
require.Equal(t, user.Everyone, users[3].Name)
// Delete user via API
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check user was deleted
users, err = s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "emma", users[1].Name)
require.Equal(t, user.Everyone, users[2].Name)
// Reject invalid user change
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
})
}
func TestUser_AddWithPasswordHash(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Create user via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check that user can login with password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, user.RoleAdmin, users[0].Role)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
})
}
func TestUser_ChangeUserPassword(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Create user via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Try to login with first password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
// Change password via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password": "ben-two"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Make sure first password fails
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
// Try to login with second password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben-two"),
})
require.Equal(t, 200, rr.Code)
})
}
func TestUser_ChangeUserTier(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin, tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier1",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier2",
}))
// Create user with tier via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
require.Equal(t, "tier1", users[1].Tier.Code)
// Change user tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "tier": "tier2"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users again
users, err = s.userManager.Users()
require.Nil(t, err)
require.Equal(t, "tier2", users[1].Tier.Code)
})
}
func TestUser_ChangeUserPasswordAndTier(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin, tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier1",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier2",
}))
// Create user with tier via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
require.Equal(t, "tier1", users[1].Tier.Code)
// Change user password and tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben-two", "tier": "tier2"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Make sure first password fails
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
// Try to login with second password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben-two"),
})
require.Equal(t, 200, rr.Code)
// Check new tier
users, err = s.userManager.Users()
require.Nil(t, err)
require.Equal(t, "tier2", users[1].Tier.Code)
})
}
func TestUser_ChangeUserPasswordWithHash(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Create user with tier via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"not-ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Try to login with first password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "not-ben"),
})
require.Equal(t, 200, rr.Code)
// Change user password and tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Try to login with second password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
})
}
func TestUser_DontChangeAdminPassword(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false))
// Try to change password via API
rr := request(t, s, "PUT", "/v1/users", `{"username": "admin", "password": "admin-new"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 403, rr.Code)
require.Equal(t, 40302, toHTTPError(t, rr.Body.String()).Code)
})
}
func TestUser_AddRemove_Failures(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Cannot create user with invalid username
rr := request(t, s, "POST", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
// Cannot create user if user already exists
rr = request(t, s, "POST", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
// Cannot create user with invalid tier
rr = request(t, s, "POST", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
// Cannot delete user as non-admin
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
// Delete user via API
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
})
}
func TestUser_ChangeRemoveProvisionedUser(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.AuthUsers = []*user.User{
{Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass
}
s := newTestServer(t, conf)
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Cannot delete provisioned user
rr := request(t, s, "DELETE", "/v1/users", `{"username": "philuser"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 409, rr.Code)
require.Equal(t, 40905, toHTTPError(t, rr.Body.String()).Code)
// Cannot change password of provisioned user
rr = request(t, s, "PUT", "/v1/users", `{"username": "philuser", "password": "newpass"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 409, rr.Code)
require.Equal(t, 40905, toHTTPError(t, rr.Body.String()).Code)
// User is still there, with the old password
u, err := s.userManager.Authenticate("philuser", "philpass")
require.Nil(t, err)
require.Equal(t, "philuser", u.Name)
})
}
func TestAccess_AllowReset(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
c := newTestConfigWithAuthFile(t, databaseURL)
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
defer s.closeDatabases()
// User and admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Subscribing not allowed
rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 403, rr.Code)
// Grant access
rr = request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Now subscribing is allowed
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
// Reset access
rr = request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gold"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Subscribing not allowed (again)
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 403, rr.Code)
})
}
func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
c := newTestConfigWithAuthFile(t, databaseURL)
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
defer s.closeDatabases()
// User
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Grant access fails, because non-admin
rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
})
}
func TestAccess_AllowReset_KillConnection(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
c := newTestConfigWithAuthFile(t, databaseURL)
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
defer s.closeDatabases()
// User and admin, grant access to "gol*" topics
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard!
start, timeTaken := time.Now(), atomic.Int64{}
go func() {
rr := request(t, s, "GET", "/gold/json", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
timeTaken.Store(time.Since(start).Milliseconds())
}()
time.Sleep(500 * time.Millisecond)
// Reset access
rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Wait for connection to be killed; this will fail if the connection is never killed
waitFor(t, func() bool {
return timeTaken.Load() >= 500
})
})
}
func TestAccess_WritesTellPeers(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
// Subscribers are per node: an access write tells the other nodes to reload their access
// cache (which ends the streams that lost access there). Deleting a user also asks them to
// cancel the user's streams, since a user without access entries (e.g. an admin) leaves
// nothing in the cache to find.
c := newTestConfigWithAuthFile(t, databaseURL)
s := newTestServer(t, c)
defer s.closeDatabases()
b := &fakeCluster{}
s.cluster = b
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead))
ben, err := s.userManager.User("ben")
require.Nil(t, err)
admin := map[string]string{"Authorization": util.BasicAuth("phil", "phil")}
before := b.AccessChanged()
rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, admin)
require.Equal(t, 200, rr.Code)
require.Greater(t, b.AccessChanged(), before)
require.Empty(t, b.DeletedUsers())
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, admin)
require.Equal(t, 200, rr.Code)
require.Equal(t, []string{ben.ID}, b.DeletedUsers())
})
}
func TestAccess_PeerAccessChangedReloadsAndCancels(t *testing.T) {
// Told by a peer that access changed, this node reloads its access cache: the reload ends the
// subscriber who lost access, and the client's reconnect is denied, not let in by a stale entry
dsn := dbtest.CreateTestPostgresSchema(t)
newNode := func() *Server {
conf := newTestConfig(t, dsn)
conf.AuthDefault = user.PermissionDenyAll
conf.AuthAccessCacheEnabled = true
conf.AuthAccessCacheReloadInterval = time.Hour
return newTestServer(t, conf)
}
a := newNode()
require.Nil(t, a.userManager.AddUser("alice", "password", user.RoleUser, false))
require.Nil(t, a.userManager.AllowAccess("alice", "private", user.PermissionRead))
b := newNode()
headers := map[string]string{"Authorization": util.BasicAuth("alice", "password")}
stream := subscribeStream(t, b, "private", headers)
require.Nil(t, a.userManager.ResetAccess("alice", "private"))
b.handlePeerAccessChanged()
select {
case <-stream.ended:
case <-time.After(5 * time.Second):
t.Fatal("alice's stream was not canceled")
}
require.Equal(t, 403, request(t, b, "GET", "/private/json?poll=1", "", headers).Code)
}
func TestAccess_PeerUsersDeletedCancels(t *testing.T) {
// A peer deleted a user: this node cancels the user's streams
s := newTestServer(t, newTestConfigWithAuthFile(t, ""))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleAdmin, false))
ben, err := s.userManager.User("ben")
require.Nil(t, err)
stream := subscribeStream(t, s, "mytopic", map[string]string{"Authorization": util.BasicAuth("ben", "ben")})
s.handleUsersDeleted([]string{ben.ID})
select {
case <-stream.ended:
case <-time.After(5 * time.Second):
t.Fatal("ben's stream was not canceled")
}
}
func TestAccess_ExternalRevocationCaughtByAccessReload(t *testing.T) {
// An access change made elsewhere (another node whose relay got lost, or the ntfy access CLI)
// reaches node B at its next access cache reload. B ends the streams of the user who lost
// access, and only those: carol, still allowed on the same topic, keeps her stream.
dsn := dbtest.CreateTestPostgresSchema(t)
newNode := func() *Server {
conf := newTestConfig(t, dsn)
conf.AuthDefault = user.PermissionDenyAll
conf.AuthAccessCacheEnabled = true
conf.AuthAccessCacheReloadInterval = 500 * time.Millisecond
return newTestServer(t, conf)
}
a := newNode()
for _, name := range []string{"alice", "carol"} {
require.Nil(t, a.userManager.AddUser(name, "password", user.RoleUser, false))
require.Nil(t, a.userManager.AllowAccess(name, "private", user.PermissionRead))
}
b := newNode()
alice := map[string]string{"Authorization": util.BasicAuth("alice", "password")}
carol := map[string]string{"Authorization": util.BasicAuth("carol", "password")}
aliceStream := subscribeStream(t, b, "private", alice)
carolStream := subscribeStream(t, b, "private", carol)
require.Nil(t, a.userManager.ResetAccess("alice", "private"))
select {
case <-aliceStream.ended:
case <-time.After(5 * time.Second):
t.Fatal("alice's stream on node B was not canceled by its access cache reload")
}
require.Equal(t, 403, request(t, b, "GET", "/private/json?poll=1", "", alice).Code)
time.Sleep(time.Second) // Two more reloads
select {
case <-carolStream.ended:
t.Fatal("carol's stream was canceled, but she can still read the topic")
default:
}
}
func TestAccess_RecheckUsesEachSubscriptionsOwnUser(t *testing.T) {
// Two streams of one user can carry different users as of subscribing, e.g. one opened before a
// demotion (admin) and one after (user). A revoke is checked against each stream's own user: the
// regular stream ends, the admin one stays (an accepted limit until it reconnects).
conf := newTestConfigWithAuthFile(t, "")
conf.AuthDefault = user.PermissionDenyAll
conf.AuthAccessCacheEnabled = true
conf.AuthAccessCacheReloadInterval = time.Hour
s := newTestServer(t, conf)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "private", user.PermissionRead))
ben, err := s.userManager.User("ben")
require.Nil(t, err)
benAsAdmin := &user.User{
ID: ben.ID,
Name: ben.Name,
Role: user.RoleAdmin,
}
topics, err := s.topicsFromIDs(nil, "private")
require.Nil(t, err)
var adminCanceled, userCanceled atomic.Bool
noop := func(*visitor, *model.Message) error {
return nil
}
topics[0].Subscribe(noop, benAsAdmin, func() {
adminCanceled.Store(true)
})
topics[0].Subscribe(noop, ben, func() {
userCanceled.Store(true)
})
require.Nil(t, s.userManager.ResetAccess("ben", "private")) // Reloads the cache and re-checks
require.True(t, userCanceled.Load())
require.False(t, adminCanceled.Load())
}
func TestAccess_RevokeBetweenAuthorizeAndSubscribe(t *testing.T) {
// A revoke, and its re-check of live subscribers, can land after a request was authorized but
// before its stream subscribed: no later re-check would find the stream, so it must not slip through
conf := newTestConfigWithAuthFile(t, "")
conf.AuthDefault = user.PermissionDenyAll
conf.AuthAccessCacheEnabled = true
conf.AuthAccessCacheReloadInterval = time.Hour
s := newTestServer(t, conf)
require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("alice", "private", user.PermissionRead))
testHookBeforeSubscribe = func() {
require.Nil(t, s.userManager.ResetAccess("alice", "private"))
}
t.Cleanup(func() {
testHookBeforeSubscribe = nil
})
done := make(chan int)
go func() {
rr := request(t, s, "GET", "/private/json", "", map[string]string{
"Authorization": util.BasicAuth("alice", "alice"),
})
done <- rr.Code
}()
select {
case code := <-done:
require.Equal(t, 403, code)
case <-time.After(3 * time.Second):
t.Fatal("the stream subscribed after the revoke stayed open")
}
}
func TestUser_GetOne(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "bens-topic", user.PermissionRead))
auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")}
// Only the requested user, so the CLI does not download all users to look up one
rr := request(t, s, "GET", "/v1/users?username=ben", "", auth)
require.Equal(t, 200, rr.Code)
var users []*apiUserResponse
require.Nil(t, json.NewDecoder(rr.Body).Decode(&users))
require.Len(t, users, 1)
require.Equal(t, "ben", users[0].Username)
require.Equal(t, []*apiUserGrantResponse{{Topic: "bens-topic", Permission: "read-only"}}, users[0].Grants)
// Unknown user
rr = request(t, s, "GET", "/v1/users?username=nobody", "", auth)
require.Equal(t, 40031, toHTTPError(t, rr.Body.String()).Code)
})
}
func TestUser_GetWithDetails(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.AuthDefault = user.PermissionRead
conf.AuthUsers = []*user.User{
{Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass
}
conf.AuthAccess = map[string][]*user.Grant{
"philuser": {{TopicPattern: "provisioned-topic", Permission: user.PermissionReadWrite}},
}
s := newTestServer(t, conf)
defer s.closeDatabases()
// Admin, a user with a tier and a manual grant
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{Code: "pro", Name: "Pro"}))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("ben", "pro"))
require.Nil(t, s.userManager.AllowAccess("ben", "bens-topic", user.PermissionRead))
rr := request(t, s, "GET", "/v1/users", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
var users []*apiUserResponse
require.Nil(t, json.NewDecoder(rr.Body).Decode(&users))
byName := make(map[string]*apiUserResponse)
for _, u := range users {
byName[u.Username] = u
}
// Tier code and display name
require.Equal(t, "pro", byName["ben"].Tier)
require.Equal(t, "Pro", byName["ben"].TierName)
require.False(t, byName["ben"].Provisioned)
require.Equal(t, []*apiUserGrantResponse{{Topic: "bens-topic", Permission: "read-only"}}, byName["ben"].Grants)
// Provisioned user and grant
require.True(t, byName["philuser"].Provisioned)
require.Equal(t, []*apiUserGrantResponse{{Topic: "provisioned-topic", Permission: "read-write", Provisioned: true}}, byName["philuser"].Grants)
// Default access only on the everyone entry
require.Equal(t, "read-only", byName[user.Everyone].DefaultAccess)
require.Equal(t, "", byName["ben"].DefaultAccess)
require.Equal(t, "", byName["phil"].DefaultAccess)
})
}
func TestUser_AddWithRole(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Role over HTTP is refused, even for admins
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "role":"admin"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 403, rr.Code)
require.Equal(t, 40302, toHTTPError(t, rr.Body.String()).Code)
_, err := s.userManager.User("ben")
require.ErrorIs(t, err, user.ErrUserNotFound)
// Invalid role over the socket
rr = requestAdminSocket(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "role":"superuser"}`)
require.Equal(t, 400, rr.Code)
require.Equal(t, 40066, toHTTPError(t, rr.Body.String()).Code)
// Role over the socket works
rr = requestAdminSocket(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "role":"admin"}`)
require.Equal(t, 200, rr.Code)
u, err := s.userManager.User("ben")
require.Nil(t, err)
require.Equal(t, user.RoleAdmin, u.Role)
})
}
func TestUser_AddWithInvalidHash(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "hash":"not-a-bcrypt-hash"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
require.Equal(t, 40069, toHTTPError(t, rr.Body.String()).Code)
// The error names the JSON fields as they are spelled
rr = request(t, s, "POST", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
require.Contains(t, toHTTPError(t, rr.Body.String()).Message, `"password" or "hash" missing`)
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
require.Contains(t, toHTTPError(t, rr.Body.String()).Message, `"password", "hash", "tier" or "role"`)
})
}
func TestUser_ChangeTierReset(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{Code: "pro", Name: "Pro"}))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("ben", "pro"))
rr := request(t, s, "PUT", "/v1/users", `{"username": "ben", "tier":"-"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
u, err := s.userManager.User("ben")
require.Nil(t, err)
require.Nil(t, u.Tier)
})
}
func TestUser_ChangeTierTooManyReservations(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{Code: "big", Name: "Big", ReservationLimit: 5}))
require.Nil(t, s.userManager.AddTier(&user.Tier{Code: "small", Name: "Small", ReservationLimit: 1}))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("ben", "big"))
require.Nil(t, s.userManager.AddReservation("ben", "topic1", user.PermissionDenyAll, 5))
require.Nil(t, s.userManager.AddReservation("ben", "topic2", user.PermissionDenyAll, 5))
rr := request(t, s, "PUT", "/v1/users", `{"username": "ben", "tier":"small"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
require.Equal(t, 40063, toHTTPError(t, rr.Body.String()).Code)
})
}
func TestUser_ChangeRole(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Refused over HTTP
rr := request(t, s, "PUT", "/v1/users", `{"username": "ben", "role":"admin"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 403, rr.Code)
require.Equal(t, 40302, toHTTPError(t, rr.Body.String()).Code)
// Invalid role, and missing user over the socket
rr = requestAdminSocket(t, s, "PUT", "/v1/users", `{"username": "ben", "role":"superuser"}`)
require.Equal(t, 40066, toHTTPError(t, rr.Body.String()).Code)
rr = requestAdminSocket(t, s, "PUT", "/v1/users", `{"username": "emma", "role":"admin"}`)
require.Equal(t, 40031, toHTTPError(t, rr.Body.String()).Code)
_, err := s.userManager.User("emma")
require.ErrorIs(t, err, user.ErrUserNotFound)
// Promote and demote over the socket
rr = requestAdminSocket(t, s, "PUT", "/v1/users", `{"username": "ben", "role":"admin"}`)
require.Equal(t, 200, rr.Code)
u, err := s.userManager.User("ben")
require.Nil(t, err)
require.Equal(t, user.RoleAdmin, u.Role)
rr = requestAdminSocket(t, s, "PUT", "/v1/users", `{"username": "ben", "role":"user"}`)
require.Equal(t, 200, rr.Code)
u, err = s.userManager.User("ben")
require.Nil(t, err)
require.Equal(t, user.RoleUser, u.Role)
})
}
func TestUser_ChangeAdminPasswordOverSocket(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false))
rr := requestAdminSocket(t, s, "PUT", "/v1/users", `{"username": "admin", "password": "admin-new"}`)
require.Equal(t, 200, rr.Code)
_, err := s.userManager.Authenticate("admin", "admin-new")
require.Nil(t, err)
})
}
func TestUser_DeleteAdmin(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false))
// Refused over HTTP
rr := request(t, s, "DELETE", "/v1/users", `{"username": "admin"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 403, rr.Code)
require.Equal(t, 40302, toHTTPError(t, rr.Body.String()).Code)
// Allowed over the socket
rr = requestAdminSocket(t, s, "DELETE", "/v1/users", `{"username": "admin"}`)
require.Equal(t, 200, rr.Code)
_, err := s.userManager.User("admin")
require.ErrorIs(t, err, user.ErrUserNotFound)
})
}
func TestUser_DeleteRemovesWebPushSubscriptions(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, configureAuth(t, newTestConfigWithWebPush(t, databaseURL)))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
u, err := s.userManager.User("ben")
require.Nil(t, err)
require.Nil(t, s.webPush.UpsertSubscription(testWebPushEndpoint, "kSC3T8aN1JCQxxPdrFLrZg", "BMKKbxdUU_xLS7G1Wh5AN8PvWOjCzkCuKZYb8apcqYrDxjOF_2piggBnoJLQYx9IeSD70fNuwawI3e9Y8m3S3PE", u.ID, netip.MustParseAddr("1.2.3.4"), []string{"test-topic"}))
requireSubscriptionCount(t, s, "test-topic", 1)
rr := requestAdminSocket(t, s, "DELETE", "/v1/users", `{"username": "ben"}`)
require.Equal(t, 200, rr.Code)
requireSubscriptionCount(t, s, "test-topic", 0)
})
}
func TestAccess_AllowAdminUser(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
rr := requestAdminSocket(t, s, "PUT", "/v1/users/access", `{"username": "phil", "topic":"gold", "permission":"ro"}`)
require.Equal(t, 400, rr.Code)
require.Equal(t, 40064, toHTTPError(t, rr.Body.String()).Code)
})
}
func TestAccess_ResetAll(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AddUser("emma", "emma", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "gold", user.PermissionRead))
require.Nil(t, s.userManager.AllowAccess("emma", "silver", user.PermissionRead))
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "public", user.PermissionRead))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Over HTTP, resetting the whole ACL is refused, even for admins
rr := request(t, s, "DELETE", "/v1/users/access", `{}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 403, rr.Code)
require.Equal(t, 40302, toHTTPError(t, rr.Body.String()).Code)
grants, err := s.userManager.AllGrants()
require.Nil(t, err)
require.Len(t, grants, 3)
// Over the socket, it works
rr = requestAdminSocket(t, s, "DELETE", "/v1/users/access", `{}`)
require.Equal(t, 200, rr.Code)
grants, err = s.userManager.AllGrants()
require.Nil(t, err)
require.Empty(t, grants)
})
}
func TestAccess_ResetUserAllTopics(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AddUser("emma", "emma", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "gold", user.PermissionRead))
require.Nil(t, s.userManager.AllowAccess("ben", "silver", user.PermissionRead))
require.Nil(t, s.userManager.AllowAccess("emma", "silver", user.PermissionRead))
rr := requestAdminSocket(t, s, "DELETE", "/v1/users/access", `{"username":"ben"}`)
require.Equal(t, 200, rr.Code)
bensGrants, err := s.userManager.Grants("ben")
require.Nil(t, err)
require.Empty(t, bensGrants)
emmasGrants, err := s.userManager.Grants("emma")
require.Nil(t, err)
require.Len(t, emmasGrants, 1)
// Unknown user
rr = requestAdminSocket(t, s, "DELETE", "/v1/users/access", `{"username":"nobody"}`)
require.Equal(t, 40031, toHTTPError(t, rr.Body.String()).Code)
})
}
func TestTokens_AddListRemove(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AddUser("emma", "emma", user.RoleUser, false))
auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")}
// No tokens yet: empty array, not null
rr := request(t, s, "GET", "/v1/users/tokens?username=ben", "", auth)
require.Equal(t, 200, rr.Code)
require.Equal(t, "[]", strings.TrimSpace(rr.Body.String()))
// Add a token that never expires, and one that does
rr = request(t, s, "POST", "/v1/users/tokens", `{"username":"ben","label":"forever"}`, auth)
require.Equal(t, 200, rr.Code)
var forever apiUserTokenResponse
require.Nil(t, json.NewDecoder(rr.Body).Decode(&forever))
require.Equal(t, "ben", forever.Username)
require.Equal(t, "forever", forever.Label)
require.Equal(t, int64(0), forever.Expires)
require.True(t, user.ValidToken(forever.Token))
require.Equal(t, int64(0), forever.LastAccess) // Never used
require.Equal(t, "", forever.LastOrigin)
expires := time.Now().Add(time.Hour).Unix()
rr = request(t, s, "POST", "/v1/users/tokens", fmt.Sprintf(`{"username":"emma","expires":%d}`, expires), auth)
require.Equal(t, 200, rr.Code)
var expiring apiUserTokenResponse
require.Nil(t, json.NewDecoder(rr.Body).Decode(&expiring))
require.Equal(t, expires, expiring.Expires)
// The new token works
rr = request(t, s, "GET", "/v1/account", "", map[string]string{"Authorization": util.BearerAuth(forever.Token)})
require.Equal(t, 200, rr.Code)
// Listing all users' tokens is refused over HTTP, but works over the socket
rr = request(t, s, "GET", "/v1/users/tokens", "", auth)
require.Equal(t, 403, rr.Code)
require.Equal(t, 40302, toHTTPError(t, rr.Body.String()).Code)
rr = requestAdminSocket(t, s, "GET", "/v1/users/tokens", "")
require.Equal(t, 200, rr.Code)
var tokens []*apiUserTokenResponse
require.Nil(t, json.NewDecoder(rr.Body).Decode(&tokens))
require.Len(t, tokens, 2)
// List one user
rr = request(t, s, "GET", "/v1/users/tokens?username=ben", "", auth)
require.Equal(t, 200, rr.Code)
tokens = nil
require.Nil(t, json.NewDecoder(rr.Body).Decode(&tokens))
require.Len(t, tokens, 1)
require.Equal(t, forever.Token, tokens[0].Token)
// Remove
rr = request(t, s, "DELETE", "/v1/users/tokens", fmt.Sprintf(`{"username":"ben","token":"%s"}`, forever.Token), auth)
require.Equal(t, 200, rr.Code)
bensTokens, err := s.userManager.Tokens(mustUserID(t, s, "ben"))
require.Nil(t, err)
require.Empty(t, bensTokens)
})
}
func TestTokens_Failures(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.AuthUsers = []*user.User{
{Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass
}
conf.AuthTokens = map[string][]*user.Token{
"philuser": {{Value: "tk_op56p8lz5bf3cxkz9je99v9oc37lo", Label: "provisioned"}},
}
s := newTestServer(t, conf)
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")}
// Expiry in the past
rr := request(t, s, "POST", "/v1/users/tokens", fmt.Sprintf(`{"username":"ben","expires":%d}`, time.Now().Add(-time.Hour).Unix()), auth)
require.Equal(t, 400, rr.Code)
// Unknown user, everyone
rr = request(t, s, "POST", "/v1/users/tokens", `{"username":"nobody"}`, auth)
require.Equal(t, 40031, toHTTPError(t, rr.Body.String()).Code)
rr = request(t, s, "POST", "/v1/users/tokens", `{"username":"*"}`, auth)
require.Equal(t, 400, rr.Code)
rr = request(t, s, "GET", "/v1/users/tokens?username=nobody", "", auth)
require.Equal(t, 40031, toHTTPError(t, rr.Body.String()).Code)
// Unknown token, provisioned token
rr = request(t, s, "DELETE", "/v1/users/tokens", `{"username":"ben","token":"tk_doesnotexistdoesnotexist12345"}`, auth)
require.Equal(t, 40065, toHTTPError(t, rr.Body.String()).Code)
rr = request(t, s, "DELETE", "/v1/users/tokens", `{"username":"philuser","token":"tk_op56p8lz5bf3cxkz9je99v9oc37lo"}`, auth)
require.Equal(t, 40906, toHTTPError(t, rr.Body.String()).Code)
// Non-admins cannot use the API
rr = request(t, s, "GET", "/v1/users/tokens?username=ben", "", map[string]string{"Authorization": util.BasicAuth("ben", "ben")})
require.Equal(t, 401, rr.Code)
})
}
func TestTokens_AdminTokensOnlyOverSocket(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
_, err := s.userManager.CreateToken(mustUserID(t, s, "phil"), "admin token", time.Unix(0, 0), netip.IPv4Unspecified(), false)
require.Nil(t, err)
_, err = s.userManager.CreateToken(mustUserID(t, s, "ben"), "user token", time.Unix(0, 0), netip.IPv4Unspecified(), false)
require.Nil(t, err)
auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")}
// Over HTTP, admin tokens cannot be listed, created or removed
rr := request(t, s, "GET", "/v1/users/tokens?username=phil", "", auth)
require.Equal(t, 403, rr.Code)
require.Equal(t, 40302, toHTTPError(t, rr.Body.String()).Code)
rr = request(t, s, "GET", "/v1/users/tokens?username=ben", "", auth)
require.Equal(t, 200, rr.Code)
var tokens []*apiUserTokenResponse
require.Nil(t, json.NewDecoder(rr.Body).Decode(&tokens))
require.Len(t, tokens, 1)
require.Equal(t, "ben", tokens[0].Username)
rr = request(t, s, "POST", "/v1/users/tokens", `{"username":"phil"}`, auth)
require.Equal(t, 403, rr.Code)
require.Equal(t, 40302, toHTTPError(t, rr.Body.String()).Code)
// Over the socket, they are visible and can be created
rr = requestAdminSocket(t, s, "GET", "/v1/users/tokens", "")
require.Equal(t, 200, rr.Code)
tokens = nil
require.Nil(t, json.NewDecoder(rr.Body).Decode(&tokens))
require.Len(t, tokens, 2)
rr = requestAdminSocket(t, s, "POST", "/v1/users/tokens", `{"username":"phil"}`)
require.Equal(t, 200, rr.Code)
})
}
func TestPasswordReset_Link(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, _ := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
ben, err := s.userManager.User("ben")
require.Nil(t, err)
// Link only
rr := requestAdminSocket(t, s, "POST", "/v1/users/password/reset", `{"username":"ben"}`)
require.Equal(t, 200, rr.Code)
var resp apiUsersPasswordResetResponse
require.Nil(t, json.NewDecoder(rr.Body).Decode(&resp))
require.Equal(t, "", resp.Email)
token := tokenFromLink(t, resp.Link, "https://ntfy.example.com/account/password/reset/")
link, err := s.userManager.MagicLinkByToken(token)
require.Nil(t, err)
require.Equal(t, ben.ID, link.UserID)
require.Empty(t, mailer.resetLinks)
// Send email: no primary email yet
rr = requestAdminSocket(t, s, "POST", "/v1/users/password/reset", `{"username":"ben","send_email":true}`)
require.Equal(t, 400, rr.Code)
require.Equal(t, 40067, toHTTPError(t, rr.Body.String()).Code)
// Send email to the primary email
require.Nil(t, s.userManager.AddEmail(ben.ID, "ben@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(ben.ID, "ben@example.com"))
rr = requestAdminSocket(t, s, "POST", "/v1/users/password/reset", `{"username":"ben","send_email":true}`)
require.Equal(t, 200, rr.Code)
resp = apiUsersPasswordResetResponse{}
require.Nil(t, json.NewDecoder(rr.Body).Decode(&resp))
require.Equal(t, "ben@example.com", resp.Email)
require.Equal(t, resp.Link, mailer.resetLinks["ben@example.com"])
// Unknown user
rr = requestAdminSocket(t, s, "POST", "/v1/users/password/reset", `{"username":"nobody"}`)
require.Equal(t, 40031, toHTTPError(t, rr.Body.String()).Code)
})
}
func TestPasswordReset_Failures(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.AuthUsers = []*user.User{
{Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass
}
s := newTestServer(t, conf)
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")}
// No mailer configured
rr := request(t, s, "POST", "/v1/users/password/reset", `{"username":"ben","send_email":true}`, auth)
require.Equal(t, 400, rr.Code)
// Provisioned user, admin over HTTP
rr = request(t, s, "POST", "/v1/users/password/reset", `{"username":"philuser"}`, auth)
require.Equal(t, 40905, toHTTPError(t, rr.Body.String()).Code)
rr = request(t, s, "POST", "/v1/users/password/reset", `{"username":"phil"}`, auth)
require.Equal(t, 403, rr.Code)
require.Equal(t, 40302, toHTTPError(t, rr.Body.String()).Code)
// No base URL
s.config.BaseURL = ""
rr = request(t, s, "POST", "/v1/users/password/reset", `{"username":"ben"}`, auth)
require.Equal(t, 40068, toHTTPError(t, rr.Body.String()).Code)
})
}
func TestTiers_AddListUpdateRemove(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")}
// Add
rr := request(t, s, "POST", "/v1/tiers", `{"code":"pro","name":"Pro","messages_limit":1000,"messages_expiry_duration":86400,"attachment_file_size_limit":1048576}`, auth)
require.Equal(t, 200, rr.Code)
tier, err := s.userManager.Tier("pro")
require.Nil(t, err)
require.Equal(t, int64(1000), tier.MessageLimit)
require.Equal(t, 24*time.Hour, tier.MessageExpiryDuration)
require.Nil(t, s.userManager.ChangeTier("ben", "pro"))
// List
rr = request(t, s, "GET", "/v1/tiers", "", auth)
require.Equal(t, 200, rr.Code)
var tiers []*apiAdminTier
require.Nil(t, json.NewDecoder(rr.Body).Decode(&tiers))
require.Len(t, tiers, 1)
require.Equal(t, tier.ID, tiers[0].ID)
require.Equal(t, "Pro", tiers[0].Name)
require.Equal(t, int64(86400), tiers[0].MessagesExpiryDuration)
require.Equal(t, int64(1048576), tiers[0].AttachmentFileSizeLimit)
require.Equal(t, int64(1), tiers[0].Users)
// Update
rr = request(t, s, "PUT", "/v1/tiers", `{"code":"pro","name":"Pro Plus","messages_limit":2000}`, auth)
require.Equal(t, 200, rr.Code)
tier, err = s.userManager.Tier("pro")
require.Nil(t, err)
require.Equal(t, "Pro Plus", tier.Name)
require.Equal(t, int64(2000), tier.MessageLimit)
// Remove: refused while in use, works after moving the user
rr = request(t, s, "DELETE", "/v1/tiers", `{"code":"pro"}`, auth)
require.Equal(t, 409, rr.Code)
require.Equal(t, 40910, toHTTPError(t, rr.Body.String()).Code)
require.Nil(t, s.userManager.ResetTier("ben"))
rr = request(t, s, "DELETE", "/v1/tiers", `{"code":"pro"}`, auth)
require.Equal(t, 200, rr.Code)
_, err = s.userManager.Tier("pro")
require.ErrorIs(t, err, user.ErrTierNotFound)
})
}
func TestTiers_Failures(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{Code: "pro", Name: "Pro"}))
auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")}
// Duplicate, bad code, missing name
rr := request(t, s, "POST", "/v1/tiers", `{"code":"pro","name":"Pro"}`, auth)
require.Equal(t, 40909, toHTTPError(t, rr.Body.String()).Code)
rr = request(t, s, "POST", "/v1/tiers", `{"code":"not valid","name":"X"}`, auth)
require.Equal(t, 40062, toHTTPError(t, rr.Body.String()).Code)
rr = request(t, s, "POST", "/v1/tiers", `{"code":"x"}`, auth)
require.Equal(t, 40062, toHTTPError(t, rr.Body.String()).Code)
// Update or remove a missing tier
rr = request(t, s, "PUT", "/v1/tiers", `{"code":"missing","name":"Missing"}`, auth)
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
rr = request(t, s, "DELETE", "/v1/tiers", `{"code":"missing"}`, auth)
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
// Non-admins cannot change tiers; listing them is the billing list (for older web apps), which needs payments
ben := map[string]string{"Authorization": util.BasicAuth("ben", "ben")}
rr = request(t, s, "POST", "/v1/tiers", `{"code":"ben","name":"Ben"}`, ben)
require.Equal(t, 401, rr.Code)
rr = request(t, s, "DELETE", "/v1/tiers", `{"code":"pro"}`, ben)
require.Equal(t, 401, rr.Code)
rr = request(t, s, "GET", "/v1/tiers", "", ben)
require.Equal(t, 404, rr.Code)
rr = request(t, s, "GET", "/v1/tiers", "", nil)
require.Equal(t, 404, rr.Code)
rr = request(t, s, "GET", "/v1/admin/tiers", "", auth) // Gone, never released
require.Equal(t, 404, rr.Code)
})
}
func TestAdminSocket_DefaultPath(t *testing.T) {
require.Equal(t, "/run/ntfy/admin.sock", defaultListenAdminUnixFor("linux"))
require.Equal(t, "/var/run/ntfy/admin.sock", defaultListenAdminUnixFor("darwin"))
require.Equal(t, "/var/run/ntfy/admin.sock", defaultListenAdminUnixFor("freebsd"))
require.Equal(t, "/var/run/ntfy/admin.sock", defaultListenAdminUnixFor("openbsd"))
require.Equal(t, "", defaultListenAdminUnixFor("windows"))
}
func TestAdminSocket_ListenAndRemove(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, socket := newTestServerWithAdminSocket(t, newTestConfigWithAuthFile(t, databaseURL))
// Socket exists with the right mode
stat, err := os.Stat(socket)
require.Nil(t, err)
require.Equal(t, os.ModeSocket, stat.Mode().Type())
require.Equal(t, DefaultListenAdminUnixMode, stat.Mode().Perm())
// Removed on stop
s.Stop()
_, err = os.Stat(socket)
require.ErrorIs(t, err, os.ErrNotExist)
})
}
func TestAdminSocket_UsersWithoutCredentials(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, socket := newTestServerWithAdminSocket(t, newTestConfigWithAuthFile(t, databaseURL))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
client := adminSocketClient(socket)
// List users, no Authorization header
resp, err := client.Get("http://ntfy/v1/users")
require.Nil(t, err)
require.Equal(t, 200, resp.StatusCode)
var users []*apiUserResponse
require.Nil(t, json.NewDecoder(resp.Body).Decode(&users))
resp.Body.Close()
require.Len(t, users, 2) // ben and everyone
// Create an admin: role changes work end to end
resp, err = client.Post("http://ntfy/v1/users", "application/json", strings.NewReader(`{"username":"phil","password":"phil","role":"admin"}`))
require.Nil(t, err)
require.Equal(t, 200, resp.StatusCode)
resp.Body.Close()
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Equal(t, user.RoleAdmin, u.Role)
})
}
func TestAdminSocket_NonAdminRoutes(t *testing.T) {
_, socket := newTestServerWithAdminSocket(t, newTestConfigWithAuthFile(t, ""))
client := adminSocketClient(socket)
resp, err := client.Get("http://ntfy/mytopic/json?poll=1")
require.Nil(t, err)
require.Equal(t, 404, resp.StatusCode)
resp.Body.Close()
resp, err = client.Post("http://ntfy/mytopic", "text/plain", strings.NewReader("hi"))
require.Nil(t, err)
require.Equal(t, 404, resp.StatusCode)
resp.Body.Close()
resp, err = client.Get("http://ntfy/v1/account") // A real route in handleInternal, but not an admin route
require.Nil(t, err)
require.Equal(t, 404, resp.StatusCode)
resp.Body.Close()
}
func TestAdminSocket_StaleSocketFile(t *testing.T) {
conf := newTestConfigWithAuthFile(t, "")
conf.ListenAdminUnix = filepath.Join(shortTempDir(t), "admin.sock")
stale, err := net.Listen("unix", conf.ListenAdminUnix)
require.Nil(t, err)
stale.(*net.UnixListener).SetUnlinkOnClose(false)
stale.Close() // Socket file stays behind, nobody listening (e.g. after a crash)
s := newTestServer(t, conf)
runTestServer(t, s)
waitFor(t, func() bool { return adminSocketListening(s) })
resp, err := adminSocketClient(conf.ListenAdminUnix).Get("http://ntfy/v1/users")
require.Nil(t, err)
require.Equal(t, 200, resp.StatusCode)
resp.Body.Close()
}
func TestAdminSocket_RegularFileNotRemoved(t *testing.T) {
// A misconfigured path (e.g. pointing at the database) must never be deleted
conf := newTestConfigWithAuthFile(t, "")
conf.ListenAdminUnix = filepath.Join(shortTempDir(t), "user.db")
require.Nil(t, os.WriteFile(conf.ListenAdminUnix, []byte("precious"), 0600))
s := newTestServer(t, conf)
output := captureLogUntil(t, "not a socket", func() {
runTestServer(t, s)
})
require.Contains(t, output, "not a socket")
require.False(t, adminSocketListening(s))
data, err := os.ReadFile(conf.ListenAdminUnix)
require.Nil(t, err)
require.Equal(t, "precious", string(data))
}
func TestAdminSocket_AnotherServerListening(t *testing.T) {
conf := newTestConfigWithAuthFile(t, "")
conf.ListenAdminUnix = filepath.Join(shortTempDir(t), "admin.sock")
other, err := net.Listen("unix", conf.ListenAdminUnix)
require.Nil(t, err)
defer other.Close()
s := newTestServer(t, conf)
output := captureLogUntil(t, "another server is listening", func() {
runTestServer(t, s)
})
require.Contains(t, output, "another server is listening")
require.False(t, adminSocketListening(s))
// The other listener is untouched
_, err = os.Stat(conf.ListenAdminUnix)
require.Nil(t, err)
}
func TestAdminSocket_OtherUsersSocket(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("root can open any socket")
}
conf := newTestConfigWithAuthFile(t, "")
conf.ListenAdminUnix = filepath.Join(shortTempDir(t), "admin.sock")
other, err := net.Listen("unix", conf.ListenAdminUnix)
require.Nil(t, err)
defer other.Close()
require.Nil(t, os.Chmod(conf.ListenAdminUnix, 0)) // As seen by a user who may not open it
s := newTestServer(t, conf)
output := captureLogUntil(t, "belongs to a server of another user", func() {
runTestServer(t, s)
})
require.Contains(t, output, "belongs to a server of another user")
require.False(t, adminSocketListening(s))
}
func TestAdminSocket_UnwritableDirectory(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("root can write anywhere")
}
dir := shortTempDir(t)
require.Nil(t, os.Chmod(dir, 0500))
t.Cleanup(func() { os.Chmod(dir, 0700) })
conf := newTestConfigWithAuthFile(t, "")
conf.ListenAdminUnix = filepath.Join(dir, "sub", "admin.sock")
s := newTestServer(t, conf)
output := captureLogUntil(t, "not available", func() {
runTestServer(t, s)
})
require.Contains(t, output, "Admin socket "+conf.ListenAdminUnix+" not available")
require.NotContains(t, output, "deprecated") // The CLI does not use the socket yet, so there is nothing to fall back from
require.False(t, adminSocketListening(s))
}
func TestAdminSocket_InsecureDirectoryWarning(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix file modes")
}
for _, tt := range []struct {
mode fs.FileMode
warn bool
}{
{0750, false},
{0777, true}, // Anyone can replace the socket
{0777 | fs.ModeSticky, false}, // Like /tmp: only the owner can remove or replace it
} {
dir := shortTempDir(t)
require.Nil(t, os.Chmod(dir, tt.mode))
conf := newTestConfigWithAuthFile(t, "")
conf.ListenAdminUnix = filepath.Join(dir, "admin.sock")
s := newTestServer(t, conf)
output := captureLog(t, func() {
runTestServer(t, s)
waitFor(t, func() bool { return adminSocketListening(s) }) // Warned before it listens
})
if tt.warn {
require.Contains(t, output, "Admin socket directory "+dir+" is writable by all users", tt.mode)
} else {
require.NotContains(t, output, "Admin socket directory", tt.mode)
}
}
}
func TestAdminSocket_DisabledWithoutPathOrAuth(t *testing.T) {
// No path: no listener, no warning
s := newTestServer(t, newTestConfigWithAuthFile(t, ""))
output := captureLog(t, func() {
runTestServer(t, s)
})
require.False(t, adminSocketListening(s)) // Run does not even try, so there is nothing to wait for
require.NotContains(t, output, "Admin socket")
// No auth configured: nothing to administer, so no listener either
conf := newTestConfig(t, "")
conf.ListenAdminUnix = filepath.Join(shortTempDir(t), "admin.sock")
s = newTestServer(t, conf)
runTestServer(t, s)
require.False(t, adminSocketListening(s))
_, err := os.Stat(conf.ListenAdminUnix)
require.ErrorIs(t, err, os.ErrNotExist)
}
func TestAdminSocket_AuditLog(t *testing.T) {
_, socket := newTestServerWithAdminSocket(t, newTestConfigWithAuthFile(t, ""))
output := captureLog(t, func() {
resp, err := adminSocketClient(socket).Post("http://ntfy/v1/users", "application/json", strings.NewReader(`{"username":"ben","password":"ben"}`))
require.Nil(t, err)
require.Equal(t, 200, resp.StatusCode)
resp.Body.Close()
})
require.Contains(t, output, "Adding user ben")
require.Contains(t, output, "admin_via=socket")
}
func TestAdminSocket_NoBanFeed(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
conf := newTestConfigWithAuthFile(t, "")
conf.BanFile = banFile
conf.BanWindow = time.Minute
conf.BanThreshold = 1
conf.BanWeights = ban.Weights{"*": 1}
s, socket := newTestServerWithAdminSocket(t, conf)
require.NotNil(t, s.ban)
client := adminSocketClient(socket)
for i := 0; i < 3; i++ {
resp, err := client.Get("http://ntfy/nope")
require.Nil(t, err)
require.Equal(t, 404, resp.StatusCode)
resp.Body.Close()
}
// Writes are async; Close flushes. Nothing may have been recorded.
s.ban.Close()
data, err := os.ReadFile(banFile)
if !errors.Is(err, os.ErrNotExist) {
require.Nil(t, err)
require.Empty(t, strings.TrimSpace(string(data)))
}
}
// requestAdminSocket sends a request through the admin socket handler, i.e. with socket semantics
// (no Authorization header, contextAdminSocket set), without a listener.
func requestAdminSocket(t *testing.T, s *Server, method, url, body string) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
r, err := http.NewRequest(method, url, strings.NewReader(body))
require.Nil(t, err)
s.handleAdminSocket(rr, r)
return rr
}
func mustUserID(t *testing.T, s *Server, username string) string {
u, err := s.userManager.User(username)
require.Nil(t, err)
return u.ID
}
// newTestServerWithAdminSocket starts a test server with the admin socket listening in a short
// temp dir (unix socket paths are limited to ~104 bytes, t.TempDir() can be longer on macOS)
func newTestServerWithAdminSocket(t *testing.T, conf *Config) (*Server, string) {
conf.ListenAdminUnix = filepath.Join(shortTempDir(t), "admin.sock")
s := newTestServer(t, conf)
runTestServer(t, s)
waitFor(t, func() bool { return adminSocketListening(s) })
return s, conf.ListenAdminUnix
}
// adminSocketListening reports whether Run's admin socket goroutine is listening
func adminSocketListening(s *Server) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.adminUnixListener != nil
}
// runTestServer runs the server in the background without network listeners, and waits until
// Run has started its listeners (it holds the lock while doing so)
func runTestServer(t *testing.T, s *Server) {
s.config.ListenHTTP = nil
go s.Run()
waitFor(t, func() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.closeChan != nil
})
}
// adminSocketClient returns an HTTP client that sends every request to the given unix socket
func adminSocketClient(socket string) *http.Client {
return &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", socket)
},
},
}
}
func shortTempDir(t *testing.T) string {
dir, err := os.MkdirTemp("", "ntfy-admin")
require.Nil(t, err)
t.Cleanup(func() { os.RemoveAll(dir) })
return dir
}
// captureLog runs f and returns what was logged meanwhile (at info level or above)
// captureLogUntil is captureLog for log lines written by goroutines: it waits until the output
// contains until
func captureLogUntil(t *testing.T, until string, f func()) string {
buf := &syncBuffer{}
log.SetOutput(buf)
log.SetLevel(log.InfoLevel)
defer func() {
log.SetLevel(log.ErrorLevel) // As in TestMain
log.SetOutput(struct{ io.Writer }{os.Stderr}) // Wrapped, so the log package does not treat stderr as a log file
}()
f()
waitFor(t, func() bool { return strings.Contains(buf.String(), until) })
return buf.String()
}
// syncBuffer is a bytes.Buffer that the log package and a test can use at the same time
type syncBuffer struct {
buf bytes.Buffer
mu sync.Mutex
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func captureLog(t *testing.T, f func()) string {
buf := &syncBuffer{} // Background goroutines of other tests' servers may log meanwhile, too
log.SetOutput(buf)
log.SetLevel(log.InfoLevel)
defer func() {
log.SetLevel(log.ErrorLevel) // As in TestMain
log.SetOutput(struct{ io.Writer }{os.Stderr}) // Wrapped, so the log package does not treat stderr as a log file
}()
f()
return buf.String()
}