mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
Phase 3+4
This commit is contained in:
@@ -146,6 +146,7 @@ var (
|
||||
errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil}
|
||||
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
|
||||
+24
-16
@@ -80,19 +80,20 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
|
||||
|
||||
var (
|
||||
// If changed, don't forget to update Android App and auth_sqlite.go
|
||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
||||
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
|
||||
webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app)
|
||||
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
||||
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
||||
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
||||
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
|
||||
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||
updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)
|
||||
clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
|
||||
sequenceIDRegex = topicRegex
|
||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
||||
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
|
||||
webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app)
|
||||
webAppPasswordResetRegex = regexp.MustCompile(`^/account/password/reset/[-_A-Za-z0-9]+$`) // Password-reset landing (served by the web app)
|
||||
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
||||
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
||||
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
||||
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
|
||||
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||
updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)
|
||||
clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
|
||||
sequenceIDRegex = topicRegex
|
||||
|
||||
webConfigPath = "/config.js"
|
||||
webManifestPath = "/manifest.webmanifest"
|
||||
@@ -119,7 +120,10 @@ var (
|
||||
apiAccountEmailVerifyPath = "/v1/account/email/verify"
|
||||
apiAccountEmailPrimaryPath = "/v1/account/email/primary"
|
||||
apiAccountEmailResendPath = "/v1/account/email/resend"
|
||||
webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended
|
||||
apiAccountPasswordResetRequestPath = "/v1/account/password/reset/request"
|
||||
apiAccountPasswordResetPath = "/v1/account/password/reset"
|
||||
webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended
|
||||
webAppPasswordResetPathPrefix = "/account/password/reset/" // Browser landing route; raw token appended
|
||||
apiAccountBillingPortalPath = "/v1/account/billing/portal"
|
||||
apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
|
||||
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
|
||||
@@ -626,6 +630,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.ensureUser(s.withAccountSync(s.handleAccountEmailSetPrimary))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailResendPath {
|
||||
return s.ensureUser(s.ensureEmailsEnabled(s.handleAccountEmailResend))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordResetRequestPath {
|
||||
return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountPasswordResetRequest))(w, r, v) // Unauthenticated
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordResetPath {
|
||||
return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountPasswordReset))(w, r, v) // Unauthenticated
|
||||
} else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path {
|
||||
return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)
|
||||
} else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path {
|
||||
@@ -668,8 +676,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && webAppEmailVerifyRegex.MatchString(r.URL.Path) {
|
||||
return s.ensureWebEnabled(s.handleWebAppIndex)(w, r, v) // Magic-link landing page (client-side route)
|
||||
} else if r.Method == http.MethodGet && (webAppEmailVerifyRegex.MatchString(r.URL.Path) || webAppPasswordResetRegex.MatchString(r.URL.Path)) {
|
||||
return s.ensureWebEnabled(s.handleWebAppIndex)(w, r, v) // Magic-link landing pages (client-side routes)
|
||||
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
|
||||
return s.ensureWebEnabled(s.handleTopic)(w, r, v)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
syncTopicAccountSyncEvent = "sync"
|
||||
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||
emailVerificationTokenExpiry = 24 * time.Hour // Magic-link lifetime for email verification
|
||||
passwordResetTokenExpiry = time.Hour // Magic-link lifetime for password reset (higher-privilege -> shorter)
|
||||
)
|
||||
|
||||
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
@@ -771,6 +772,77 @@ func (s *Server) enqueueEmailVerification(userID, email string) error {
|
||||
return s.mailSender.SendEmailVerification(email, link)
|
||||
}
|
||||
|
||||
// handleAccountPasswordResetRequest starts a password reset (POST /v1/account/password/reset/request,
|
||||
// unauthenticated). It resolves the identifier (username or primary email) to at most one account
|
||||
// and emails a reset link to that account's primary email. The response is always a uniform 200,
|
||||
// regardless of whether anything matched, so it cannot be used to probe for accounts.
|
||||
func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountPasswordResetRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Rate limit via the shared per-visitor account-creation bucket (no new limiter/config)
|
||||
if !v.AccountCreationAllowed() {
|
||||
return errHTTPTooManyRequestsLimitAccountCreation
|
||||
}
|
||||
v.AccountCreated() // Consume a token on every request (including no-match), to throttle probing
|
||||
identifier := strings.TrimSpace(req.Identifier)
|
||||
if identifier != "" && s.config.BaseURL != "" {
|
||||
if userID, email, ok := s.resolveResetTarget(identifier); ok {
|
||||
token, err := s.userManager.CreateMagicLink(user.MagicLinkKindPasswordReset, userID, "", passwordResetTokenExpiry)
|
||||
if err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to create password reset token")
|
||||
} else {
|
||||
link := s.config.BaseURL + webAppPasswordResetPathPrefix + token
|
||||
logvr(v, r).Tag(tagAccount).Field("user_id", userID).Info("Sending password reset link")
|
||||
if err := s.mailSender.SendPasswordReset(email, link); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send password reset email")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logvr(v, r).Tag(tagAccount).Debug("Password reset requested for unknown identifier (uniform response)")
|
||||
}
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// resolveResetTarget resolves a reset identifier to a single account and its primary email.
|
||||
// The identifier is tried first as a username, then as a primary email address. It returns
|
||||
// ok=false if no account with a primary email matches (reset requires a verified primary email).
|
||||
func (s *Server) resolveResetTarget(identifier string) (userID string, email string, ok bool) {
|
||||
if u, err := s.userManager.User(identifier); err == nil && u != nil {
|
||||
if primary, perr := s.userManager.PrimaryEmail(u.ID); perr == nil && primary != "" {
|
||||
return u.ID, primary, true
|
||||
}
|
||||
}
|
||||
if uid, err := s.userManager.UserIDByPrimaryEmail(identifier); err == nil {
|
||||
return uid, identifier, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// handleAccountPasswordReset performs the reset (POST /v1/account/password/reset, unauthenticated):
|
||||
// it validates the token and sets the new password. Existing access tokens stay valid.
|
||||
func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountPasswordResetConfirmRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Token == "" {
|
||||
return errHTTPBadRequestResetLinkInvalid
|
||||
} else if req.Password == "" {
|
||||
return errHTTPBadRequest
|
||||
}
|
||||
err = s.userManager.ResetPassword(req.Token, req.Password)
|
||||
if errors.Is(err, user.ErrMagicLinkNotFound) {
|
||||
return errHTTPBadRequestResetLinkInvalid
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Info("Password reset performed")
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// convertEmailAddress checks the email address against the user's verified email list.
|
||||
// If smtp-sender-verify is false (default), the email is passed through as-is for
|
||||
// backwards compatibility. If true, the user must be authenticated and the email must be
|
||||
|
||||
@@ -173,6 +173,89 @@ func TestAccount_Email_SetPrimaryCollision(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// verifyEmailFor runs the full add->click flow so the user ends up with a verified primary email.
|
||||
func verifyEmailFor(t *testing.T, s *Server, mailer *captureMailer, auth map[string]string, email string) {
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", fmt.Sprintf(`{"email":"%s"}`, email), auth).Code)
|
||||
token := tokenFromLink(t, mailer.verifyLinks[email], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
}
|
||||
|
||||
// canLogin returns true if username/password authenticates (via the token-create endpoint).
|
||||
func canLogin(t *testing.T, s *Server, username, password string) bool {
|
||||
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{"Authorization": util.BasicAuth(username, password)})
|
||||
return rr.Code == 200
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_ByUsername(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
|
||||
|
||||
// Request reset by username
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/")
|
||||
|
||||
// Confirm with a new password
|
||||
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
require.True(t, canLogin(t, s, "ben", "brandnew"))
|
||||
require.False(t, canLogin(t, s, "ben", "ben"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_ByEmail(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben@example.com"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/")
|
||||
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.True(t, canLogin(t, s, "ben", "brandnew"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_UnknownIdentifierUniform(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Unknown identifier still returns a uniform 200, and no email is sent
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ghost"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Empty(t, mailer.resetLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// ben exists but has no verified primary email -> uniform 200, nothing sent
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Empty(t, mailer.resetLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_InvalidToken(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, _, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset", `{"token":"nope","password":"brandnew"}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40054, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_AddDuplicateVerified(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
|
||||
@@ -237,10 +237,40 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
|
||||
if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
|
||||
return err
|
||||
}
|
||||
// Offer email recovery: auto-send a verification link to the billing email (best-effort).
|
||||
if sess.CustomerDetails != nil {
|
||||
s.maybeEnqueueBillingEmailVerification(r, v, u.ID, sess.CustomerDetails.Email)
|
||||
}
|
||||
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeEnqueueBillingEmailVerification sends an email-verification link to a paying user's
|
||||
// billing email, so they can use it for password recovery -- but only if they have no verified
|
||||
// email yet and the billing email is not already the recovery email on another account. On a
|
||||
// collision (or any other skip), the generic "no recovery email set" warning on the account page
|
||||
// nudges the user to add one. This is best-effort: failures are logged, never surfaced.
|
||||
func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visitor, userID, billingEmail string) {
|
||||
if s.mailSender == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) {
|
||||
return
|
||||
}
|
||||
emails, err := s.userManager.Emails(userID)
|
||||
if err != nil {
|
||||
logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to load emails for billing verification")
|
||||
return
|
||||
} else if len(emails) > 0 {
|
||||
return // User already has a verified email -- don't nag
|
||||
}
|
||||
if _, err := s.userManager.UserIDByPrimaryEmail(billingEmail); err == nil {
|
||||
logvr(v, r).Tag(tagStripe).Debug("Billing email is primary on another account, skipping auto-verification")
|
||||
return // Collision: skip + let the generic no-recovery-email warning nudge instead
|
||||
}
|
||||
logvr(v, r).Tag(tagStripe).Field("email", billingEmail).Info("Sending verification link to billing email")
|
||||
if err := s.enqueueEmailVerification(userID, billingEmail); err != nil {
|
||||
logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to enqueue billing email verification")
|
||||
}
|
||||
}
|
||||
|
||||
// handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates
|
||||
// a user's tier accordingly. This endpoint only works if there is an existing subscription.
|
||||
func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//go:build !nopayments
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
)
|
||||
|
||||
// stripeCheckoutMock wires up a testStripeAPI for a successful checkout of user u, with the given
|
||||
// billing email on the session's CustomerDetails.
|
||||
func stripeCheckoutMock(u *user.User, billingEmail string) *testStripeAPI {
|
||||
m := &testStripeAPI{}
|
||||
m.On("GetSession", "SOMETOKEN").Return(&stripe.CheckoutSession{
|
||||
ClientReferenceID: u.ID,
|
||||
Customer: &stripe.Customer{ID: "acct_5555"},
|
||||
Subscription: &stripe.Subscription{ID: "sub_1234"},
|
||||
CustomerDetails: &stripe.CheckoutSessionCustomerDetails{Email: billingEmail},
|
||||
}, nil)
|
||||
m.On("GetSubscription", "sub_1234").Return(&stripe.Subscription{
|
||||
ID: "sub_1234",
|
||||
Status: stripe.SubscriptionStatusActive,
|
||||
CurrentPeriodEnd: 123456789,
|
||||
Items: &stripe.SubscriptionItemList{
|
||||
Data: []*stripe.SubscriptionItem{
|
||||
{Price: &stripe.Price{ID: "price_1234", Recurring: &stripe.PriceRecurring{Interval: stripe.PriceRecurringIntervalMonth}}},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
m.On("UpdateCustomer", "acct_5555", mock.Anything).Return(&stripe.Customer{}, nil)
|
||||
return m
|
||||
}
|
||||
|
||||
func newCheckoutEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, *user.User) {
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.StripeSecretKey = "secret key"
|
||||
c.BaseURL = "https://ntfy.example.com"
|
||||
c.SMTPSenderAddr = "localhost:25"
|
||||
c.SMTPSenderFrom = "noreply@example.com"
|
||||
s := newTestServer(t, c)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailSender = mailer
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123", Code: "starter", StripeMonthlyPriceID: "price_1234", MessageLimit: 100, MessageExpiryDuration: time.Hour,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
return s, mailer, u
|
||||
}
|
||||
|
||||
func TestPayments_Checkout_SendsBillingEmailVerification(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||
require.Equal(t, 303, rr.Code)
|
||||
|
||||
// A verification link was auto-sent to the billing email; clicking it verifies + sets primary
|
||||
link := mailer.verifyLinks["billing@example.com"]
|
||||
require.NotEmpty(t, link)
|
||||
token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"billing@example.com"}, emails)
|
||||
primary, err := s.userManager.PrimaryEmail(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "billing@example.com", primary)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPayments_Checkout_SkipsBillingEmailWhenAlreadyVerified(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||
|
||||
// User already has a verified email -> no auto-send on checkout
|
||||
require.Nil(t, s.userManager.AddEmail(u.ID, "existing@example.com"))
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||
require.Equal(t, 303, rr.Code)
|
||||
require.Empty(t, mailer.verifyLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPayments_Checkout_SkipsBillingEmailWhenPrimaryElsewhere(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||
|
||||
// The billing email is already the recovery email on another account -> skip
|
||||
require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false))
|
||||
alice, err := s.userManager.User("alice")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddEmail(alice.ID, "billing@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(alice.ID, "billing@example.com"))
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||
require.Equal(t, 303, rr.Code)
|
||||
require.Empty(t, mailer.verifyLinks)
|
||||
})
|
||||
}
|
||||
@@ -238,6 +238,19 @@ type apiAccountEmailVerifyRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// apiAccountPasswordResetRequest is the body of the (unauthenticated) reset-request endpoint.
|
||||
// The identifier is a username or a primary email address.
|
||||
type apiAccountPasswordResetRequest struct {
|
||||
Identifier string `json:"identifier"`
|
||||
}
|
||||
|
||||
// apiAccountPasswordResetConfirmRequest is the body of the (unauthenticated) reset-confirm
|
||||
// endpoint, submitted from the set-new-password landing page.
|
||||
type apiAccountPasswordResetConfirmRequest struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type apiAccountTier struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
|
||||
Reference in New Issue
Block a user