Test account api (WIP)

This commit is contained in:
binwiederhier 2022-12-28 22:16:11 -05:00
parent 367d024a2d
commit 3512db1fe7
6 changed files with 144 additions and 27 deletions

View file

@ -44,7 +44,7 @@ const (
DefaultVisitorRequestLimitReplenish = 5 * time.Second
DefaultVisitorEmailLimitBurst = 16
DefaultVisitorEmailLimitReplenish = time.Hour
DefaultVisitorAccountCreateLimitBurst = 2
DefaultVisitorAccountCreateLimitBurst = 3
DefaultVisitorAccountCreateLimitReplenish = 24 * time.Hour
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB

View file

@ -50,15 +50,12 @@ import (
- figure out what settings are "web" or "phone"
UI:
- Subscription dotmenu dropdown: Move to nav bar, or make same as profile dropdown
- Translations
- aria-labels
- Home UI sign-in/login to top right
-
rate limiting:
- login/account endpoints
Pages:
- Home
- Password reset
- Pricing
- change email
Polishing:
aria-label for everything
Tests:
- APIs
- CRUD tokens
@ -66,6 +63,12 @@ import (
- userManager can be nil
- visitor with/without user
- userManager.<NEWSTUFF>
Later:
- Password reset
- Pricing
- change email
*/
// Server is the main server, providing the UI and API for ntfy
@ -1417,7 +1420,7 @@ func (s *Server) ensureUserManager(next handleFunc) handleFunc {
func (s *Server) ensureUser(next handleFunc) handleFunc {
return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if v.user == nil {
return errHTTPNotFound
return errHTTPUnauthorized
}
return next(w, r, v)
})

View file

@ -109,20 +109,16 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
}
func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
if v.user == nil {
return errHTTPUnauthorized
}
if err := s.userManager.RemoveUser(v.user.Name); err != nil {
return err
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
// FIXME return something
return nil
}
func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
newPassword, err := util.ReadJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit)
newPassword, err := util.ReadJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit)
if err != nil {
return err
}

View file

@ -1,14 +1,16 @@
package server
import (
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"io"
"testing"
"time"
)
func TestAccount_Create_Success(t *testing.T) {
func TestAccount_Signup_Success(t *testing.T) {
conf := newTestConfigWithUsers(t)
conf.EnableSignup = true
s := newTestServer(t, conf)
@ -33,7 +35,34 @@ func TestAccount_Create_Success(t *testing.T) {
require.Equal(t, "user", account.Role)
}
func TestAccount_Create_Disabled(t *testing.T) {
func TestAccount_Signup_UserExists(t *testing.T) {
conf := newTestConfigWithUsers(t)
conf.EnableSignup = true
s := newTestServer(t, conf)
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 409, rr.Code)
require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
}
func TestAccount_Signup_LimitReached(t *testing.T) {
conf := newTestConfigWithUsers(t)
conf.EnableSignup = true
s := newTestServer(t, conf)
for i := 0; i < 3; i++ {
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
require.Equal(t, 200, rr.Code)
}
rr := request(t, s, "POST", "/v1/account", `{"username":"thiswontwork", "password":"mypass"}`, nil)
require.Equal(t, 429, rr.Code)
require.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code)
}
func TestAccount_Signup_Disabled(t *testing.T) {
conf := newTestConfigWithUsers(t)
conf.EnableSignup = false
s := newTestServer(t, conf)
@ -42,3 +71,79 @@ func TestAccount_Create_Disabled(t *testing.T) {
require.Equal(t, 400, rr.Code)
require.Equal(t, 40022, toHTTPError(t, rr.Body.String()).Code)
}
func TestAccount_Get_Anonymous(t *testing.T) {
conf := newTestConfigWithUsers(t)
conf.VisitorRequestLimitReplenish = 86 * time.Second
conf.VisitorEmailLimitReplenish = time.Hour
conf.VisitorAttachmentTotalSizeLimit = 5123
conf.AttachmentFileSizeLimit = 512
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
rr := request(t, s, "GET", "/v1/account", "", nil)
require.Equal(t, 200, rr.Code)
account, _ := util.ReadJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, "*", account.Username)
require.Equal(t, string(user.RoleAnonymous), account.Role)
require.Equal(t, "ip", account.Limits.Basis)
require.Equal(t, int64(1004), account.Limits.Messages) // I hate this
require.Equal(t, int64(24), account.Limits.Emails) // I hate this
require.Equal(t, int64(5123), account.Limits.AttachmentTotalSize)
require.Equal(t, int64(512), account.Limits.AttachmentFileSize)
require.Equal(t, int64(0), account.Stats.Messages)
require.Equal(t, int64(1004), account.Stats.MessagesRemaining)
require.Equal(t, int64(0), account.Stats.Emails)
require.Equal(t, int64(24), account.Stats.EmailsRemaining)
rr = request(t, s, "POST", "/mytopic", "", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s, "POST", "/mytopic", "", map[string]string{
"Email": "phil@ntfy.sh",
})
require.Equal(t, 200, rr.Code)
rr = request(t, s, "GET", "/v1/account", "", nil)
require.Equal(t, 200, rr.Code)
account, _ = util.ReadJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, int64(2), account.Stats.Messages)
require.Equal(t, int64(1002), account.Stats.MessagesRemaining)
require.Equal(t, int64(1), account.Stats.Emails)
require.Equal(t, int64(23), account.Stats.EmailsRemaining)
}
func TestAccount_Delete_Success(t *testing.T) {
conf := newTestConfigWithUsers(t)
conf.EnableSignup = true
s := newTestServer(t, conf)
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
rr = request(t, s, "DELETE", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 401, rr.Code)
}
func TestAccount_Delete_Not_Allowed(t *testing.T) {
conf := newTestConfigWithUsers(t)
conf.EnableSignup = true
s := newTestServer(t, conf)
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s, "DELETE", "/v1/account", "", nil)
require.Equal(t, 401, rr.Code)
}

View file

@ -225,6 +225,10 @@ type apiAccountCreateRequest struct {
Password string `json:"password"`
}
type apiAccountPasswordChangeRequest struct {
Password string `json:"password"`
}
type apiAccountTokenResponse struct {
Token string `json:"token"`
Expires int64 `json:"expires"`

View file

@ -7,17 +7,6 @@ import (
"time"
)
type Auther interface {
// Authenticate checks username and password and returns a user if correct. The method
// returns in constant-ish time, regardless of whether the user exists or the password is
// correct or incorrect.
Authenticate(username, password string) (*User, error)
// Authorize returns nil if the given user has access to the given topic using the desired
// permission. The user param may be nil to signal an anonymous user.
Authorize(user *User, topic string, perm Permission) error
}
// User is a struct that represents a user
type User struct {
Name string
@ -30,25 +19,42 @@ type User struct {
Stats *Stats
}
// Auther is an interface for authentication and authorization
type Auther interface {
// Authenticate checks username and password and returns a user if correct. The method
// returns in constant-ish time, regardless of whether the user exists or the password is
// correct or incorrect.
Authenticate(username, password string) (*User, error)
// Authorize returns nil if the given user has access to the given topic using the desired
// permission. The user param may be nil to signal an anonymous user.
Authorize(user *User, topic string, perm Permission) error
}
// Token represents a user token, including expiry date
type Token struct {
Value string
Expires time.Time
}
// Prefs represents a user's configuration settings
type Prefs struct {
Language string `json:"language,omitempty"`
Notification *NotificationPrefs `json:"notification,omitempty"`
Subscriptions []*Subscription `json:"subscriptions,omitempty"`
}
// PlanCode is code identifying a user's plan
type PlanCode string
// Default plan codes
const (
PlanUnlimited = PlanCode("unlimited")
PlanDefault = PlanCode("default")
PlanNone = PlanCode("none")
)
// Plan represents a user's account type, including its account limits
type Plan struct {
Code string `json:"name"`
Upgradable bool `json:"upgradable"`
@ -58,6 +64,7 @@ type Plan struct {
AttachmentTotalSizeLimit int64 `json:"attachment_total_size_limit"`
}
// Subscription represents a user's topic subscription
type Subscription struct {
ID string `json:"id"`
BaseURL string `json:"base_url"`
@ -65,12 +72,14 @@ type Subscription struct {
DisplayName string `json:"display_name"`
}
// NotificationPrefs represents the user's notification settings
type NotificationPrefs struct {
Sound string `json:"sound,omitempty"`
MinPriority int `json:"min_priority,omitempty"`
DeleteAfter int `json:"delete_after,omitempty"`
}
// Stats is a struct holding daily user statistics
type Stats struct {
Messages int64
Emails int64