package server import ( "fmt" "io" "strings" "testing" "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) // captureMailer is a fake mailer that records the magic links it is asked to send, so tests can // "click" them without a real SMTP server. The notification side is a no-op. type captureMailer struct { verifyLinks map[string]string // email -> verification link resetLinks map[string]string // email -> reset link } func newCaptureMailer() *captureMailer { return &captureMailer{verifyLinks: map[string]string{}, resetLinks: map[string]string{}} } func (c *captureMailer) SendEmailVerification(to, link string) error { c.verifyLinks[to] = link return nil } func (c *captureMailer) SendPasswordReset(to, link string) error { c.resetLinks[to] = link return nil } func (c *captureMailer) SendNotification(to string, m *model.Message, senderIP string) error { return nil } func (c *captureMailer) NotificationCounts() (total int64, success int64, failure int64) { return 0, 0, 0 } // newEmailTestServer creates a server with email sending "enabled" (SMTP + base-url configured) // and a capturing mailer injected, plus a tier-less user "ben" logged in via basic auth. func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, map[string]string) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderAddr = "localhost:25" conf.SMTPSenderFrom = "noreply@example.com" conf.BaseURL = "https://ntfy.example.com" s := newTestServer(t, conf) mailer := newCaptureMailer() s.mailer = mailer require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) auth := map[string]string{"Authorization": util.BasicAuth("ben", "ben")} return s, mailer, auth } func getAccount(t *testing.T, s *Server, auth map[string]string) *apiAccountResponse { rr := request(t, s, "GET", "/v1/account", "", auth) require.Equal(t, 200, rr.Code) account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) require.Nil(t, err) return account } // verifiedAddrs / pendingAddrs / primaryAddr extract the addresses from the structured email // list returned by GET /v1/account, so assertions stay readable. func verifiedAddrs(account *apiAccountResponse) []string { addrs := make([]string, 0) for _, e := range account.Emails { if !e.Pending { addrs = append(addrs, e.Address) } } return addrs } func pendingAddrs(account *apiAccountResponse) []string { addrs := make([]string, 0) for _, e := range account.Emails { if e.Pending { addrs = append(addrs, e.Address) } } return addrs } func primaryAddr(account *apiAccountResponse) string { for _, e := range account.Emails { if e.Primary { return e.Address } } return "" } func tokenFromLink(t *testing.T, link, prefix string) string { require.True(t, strings.HasPrefix(link, prefix), "link %q missing prefix %q", link, prefix) return strings.TrimPrefix(link, prefix) } func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s, mailer, auth := newEmailTestServer(t, databaseURL) defer s.closeDatabases() // Start verification rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth) require.Equal(t, 200, rr.Code) // Pending, not yet verified, no primary account := getAccount(t, s, auth) require.Equal(t, []string{"ben@example.com"}, pendingAddrs(account)) require.Empty(t, verifiedAddrs(account)) require.Equal(t, "", primaryAddr(account)) // "Click" the captured link (unauthenticated POST) token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/") rr = request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil) require.Equal(t, 200, rr.Code) // Now verified + primary, no longer pending account = getAccount(t, s, auth) require.Equal(t, []string{"ben@example.com"}, verifiedAddrs(account)) require.Equal(t, "ben@example.com", primaryAddr(account)) require.Empty(t, pendingAddrs(account)) }) } func TestAccount_Email_VerifyInvalidToken(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/email/verify", `{"token":"doesnotexist"}`, nil) require.Equal(t, 400, rr.Code) require.Equal(t, 40051, toHTTPError(t, rr.Body.String()).Code) // Empty token also rejected rr = request(t, s, "POST", "/v1/account/email/verify", `{"token":""}`, nil) require.Equal(t, 400, rr.Code) }) } func TestAccount_Email_DeletePending(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s, _, auth := newEmailTestServer(t, databaseURL) defer s.closeDatabases() require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) require.Equal(t, []string{"ben@example.com"}, pendingAddrs(getAccount(t, s, auth))) // Deleting the pending address clears it (no verification ever happened) require.Equal(t, 200, request(t, s, "DELETE", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) account := getAccount(t, s, auth) require.Empty(t, pendingAddrs(account)) require.Empty(t, verifiedAddrs(account)) }) } func TestAccount_Email_Resend(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s, mailer, auth := newEmailTestServer(t, databaseURL) defer s.closeDatabases() require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) firstLink := mailer.verifyLinks["ben@example.com"] require.NotEmpty(t, firstLink) // Resend issues a fresh link (the old one is replaced) require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/resend", `{"email":"ben@example.com"}`, auth).Code) require.NotEqual(t, firstLink, mailer.verifyLinks["ben@example.com"]) // The old token no longer verifies; the new one does oldToken := tokenFromLink(t, firstLink, "https://ntfy.example.com/account/email/verify/") require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, oldToken), nil).Code) newToken := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/") require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, newToken), nil).Code) // Resending for a non-pending address is rejected require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/resend", `{"email":"never@example.com"}`, auth).Code) }) } func TestAccount_Email_SetPrimaryCollision(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s, mailer, auth := newEmailTestServer(t, databaseURL) defer s.closeDatabases() // ben verifies shared@ -> becomes his primary require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, auth).Code) benToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/") require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, benToken), nil).Code) require.Equal(t, "shared@example.com", primaryAddr(getAccount(t, s, auth))) // alice verifies the same address -> allowed as secondary, but it is not her primary require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) aliceAuth := map[string]string{"Authorization": util.BasicAuth("alice", "alice")} require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, aliceAuth).Code) aliceToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/") require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, aliceToken), nil).Code) aliceAccount := getAccount(t, s, aliceAuth) require.Equal(t, []string{"shared@example.com"}, verifiedAddrs(aliceAccount)) require.Equal(t, "", primaryAddr(aliceAccount)) // alice trying to promote it to primary collides with ben's rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"shared@example.com"}`, aliceAuth) require.Equal(t, 409, rr.Code) require.Equal(t, 40908, toHTTPError(t, rr.Body.String()).Code) }) } // 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_EmailLookalikeUsernameDoesNotShadow(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s, mailer, auth := newEmailTestServer(t, databaseURL) defer s.closeDatabases() // Account A (the email owner): user "ben" with verified primary email "phil@example.com" verifyEmailFor(t, s, mailer, auth, "phil@example.com") // Account B (the squatter): a different account whose USERNAME looks like A's email, with // its own, different verified primary email require.Nil(t, s.userManager.AddUser("phil@example.com", "squatterpass", user.RoleUser, false)) squatter, err := s.userManager.User("phil@example.com") require.Nil(t, err) require.Nil(t, s.userManager.AddEmail(squatter.ID, "squatter@example.com")) require.Nil(t, s.userManager.SetPrimaryEmail(squatter.ID, "squatter@example.com")) // Reset by the ambiguous identifier: the verified email must win over the look-alike username rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"phil@example.com"}`, nil) require.Equal(t, 200, rr.Code) require.NotEmpty(t, mailer.resetLinks["phil@example.com"]) // sent to the email owner (account A) require.Empty(t, mailer.resetLinks["squatter@example.com"]) // NOT the username squatter (account B) // The token resets account A (ben); the squatter's password is untouched token := tokenFromLink(t, mailer.resetLinks["phil@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")) // account A was reset require.True(t, canLogin(t, s, "phil@example.com", "squatterpass")) // account B unaffected }) } 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_Signup_WithEmail_SendsVerification(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.EnableSignup = true conf.SMTPSenderAddr = "localhost:25" conf.SMTPSenderFrom = "noreply@example.com" conf.BaseURL = "https://ntfy.example.com" s := newTestServer(t, conf) mailer := newCaptureMailer() s.mailer = mailer defer s.closeDatabases() // Sign up with an optional email -> account created and a verification link sent rr := request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass","email":"emma@example.com"}`, nil) require.Equal(t, 200, rr.Code) link := mailer.verifyLinks["emma@example.com"] require.NotEmpty(t, link) // Verifying the link makes it the (first) primary email 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) account := getAccount(t, s, map[string]string{"Authorization": util.BasicAuth("emma", "emmapass")}) require.Equal(t, []string{"emma@example.com"}, verifiedAddrs(account)) require.Equal(t, "emma@example.com", primaryAddr(account)) }) } func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.EnableSignup = true conf.SMTPSenderAddr = "localhost:25" conf.SMTPSenderFrom = "noreply@example.com" conf.BaseURL = "https://ntfy.example.com" s := newTestServer(t, conf) mailer := newCaptureMailer() s.mailer = mailer defer s.closeDatabases() // No email -> account created, nothing sent require.Equal(t, 200, request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass"}`, nil).Code) require.Empty(t, mailer.verifyLinks) // Invalid email -> rejected rr := request(t, s, "POST", "/v1/account", `{"username":"otto","password":"ottopass","email":"not-an-email"}`, nil) require.Equal(t, 400, rr.Code) require.Equal(t, 40050, toHTTPError(t, rr.Body.String()).Code) }) } func TestAccount_Email_ProvisionedPrimary(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost) require.Nil(t, err) conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderAddr = "localhost:25" conf.SMTPSenderFrom = "noreply@example.com" conf.BaseURL = "https://ntfy.example.com" conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}} s := newTestServer(t, conf) mailer := newCaptureMailer() s.mailer = mailer defer s.closeDatabases() auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")} // A provisioned user's first verified email becomes their primary (used by X-Email: yes; // password reset stays blocked separately for provisioned users) verifyEmailFor(t, s, mailer, auth, "prov@example.com") account := getAccount(t, s, auth) require.Equal(t, []string{"prov@example.com"}, verifiedAddrs(account)) require.Equal(t, "prov@example.com", primaryAddr(account)) // Verify a second address and explicitly set it primary -> allowed, star moves verifyEmailFor(t, s, mailer, auth, "prov2@example.com") rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"prov2@example.com"}`, auth) require.Equal(t, 200, rr.Code) account = getAccount(t, s, auth) require.Equal(t, "prov2@example.com", primaryAddr(account)) }) } func TestAccount_PasswordReset_ProvisionedUserNoSend(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { // Provision a user via config (AuthUsers), with email sending enabled conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderAddr = "localhost:25" conf.SMTPSenderFrom = "noreply@example.com" conf.BaseURL = "https://ntfy.example.com" conf.AuthUsers = []*user.User{ {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser}, } s := newTestServer(t, conf) mailer := newCaptureMailer() s.mailer = mailer defer s.closeDatabases() // Give the provisioned user a verified primary email anyway prov, err := s.userManager.User("prov") require.Nil(t, err) require.True(t, prov.Provisioned) require.Nil(t, s.userManager.AddEmail(prov.ID, "prov@example.com")) require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "prov@example.com")) // Reset request by username and by email -> uniform 200, but no email sent (can't reset) require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov"}`, nil).Code) require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov@example.com"}`, nil).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) defer s.closeDatabases() require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "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) // Adding the same already-verified address is a conflict rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth) require.Equal(t, 409, rr.Code) require.Equal(t, 40907, toHTTPError(t, rr.Body.String()).Code) }) }