Merge user store and manager

This commit is contained in:
binwiederhier
2026-02-28 17:35:35 -05:00
parent 7710ace184
commit ebb48e217d
11 changed files with 1816 additions and 2094 deletions

View File

@@ -378,25 +378,19 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
BcryptCost: user.DefaultUserPasswordBcryptCost,
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
}
var store user.Store
if databaseURL != "" {
pool, dbErr := db.OpenPostgres(databaseURL)
if dbErr != nil {
return nil, dbErr
}
store, err = user.NewPostgresStore(pool)
return user.NewPostgresManager(pool, authConfig)
} else if authFile != "" {
if !util.FileExists(authFile) {
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
}
store, err = user.NewSQLiteStore(authFile, authStartupQueries)
} else {
return nil, errors.New("option database-url or auth-file not set; auth is unconfigured for this server")
return user.NewSQLiteManager(authFile, authStartupQueries, authConfig)
}
if err != nil {
return nil, err
}
return user.NewManager(store, authConfig)
return nil, errors.New("option database-url or auth-file not set; auth is unconfigured for this server")
}
func readPasswordAndConfirm(c *cli.Context) (string, error) {

View File

@@ -235,19 +235,14 @@ func New(conf *Config) (*Server, error) {
BcryptCost: conf.AuthBcryptCost,
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
}
var store user.Store
if pool != nil {
store, err = user.NewPostgresStore(pool)
userManager, err = user.NewPostgresManager(pool, authConfig)
} else {
store, err = user.NewSQLiteStore(conf.AuthFile, conf.AuthStartupQueries)
userManager, err = user.NewSQLiteManager(conf.AuthFile, conf.AuthStartupQueries, authConfig)
}
if err != nil {
return nil, err
}
userManager, err = user.NewManager(store, authConfig)
if err != nil {
return nil, err
}
}
var firebaseClient *firebaseClient
if conf.FirebaseKeyFile != "" {

File diff suppressed because it is too large Load Diff

View File

@@ -203,13 +203,14 @@ const (
`
)
// NewPostgresStore creates a new PostgreSQL-backed user store using an existing database connection pool.
func NewPostgresStore(db *sql.DB) (Store, error) {
// NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool.
func NewPostgresManager(db *sql.DB, config *Config) (*Manager, error) {
if err := setupPostgres(db); err != nil {
return nil, err
}
return &commonStore{
db: db,
manager := &Manager{
config: config,
db: db,
queries: storeQueries{
// User queries
selectUserByID: postgresSelectUserByIDQuery,
@@ -277,5 +278,9 @@ func NewPostgresStore(db *sql.DB) (Store, error) {
// Billing queries
updateBilling: postgresUpdateBillingQuery,
},
}, nil
}
if err := initManager(manager); err != nil {
return nil, err
}
return manager, nil
}

View File

@@ -201,8 +201,8 @@ const (
`
)
// NewSQLiteStore creates a new SQLite-backed user store
func NewSQLiteStore(filename, startupQueries string) (Store, error) {
// NewSQLiteManager creates a new Manager backed by a SQLite database
func NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager, error) {
parentDir := filepath.Dir(filename)
if !util.FileExists(parentDir) {
return nil, fmt.Errorf("user database directory %s does not exist or is not accessible", parentDir)
@@ -217,8 +217,9 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) {
if err := runSQLiteStartupQueries(db, startupQueries); err != nil {
return nil, err
}
return &commonStore{
db: db,
manager := &Manager{
config: config,
db: db,
queries: storeQueries{
selectUserByID: sqliteSelectUserByIDQuery,
selectUserByName: sqliteSelectUserByNameQuery,
@@ -275,5 +276,9 @@ func NewSQLiteStore(filename, startupQueries string) (Store, error) {
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
updateBilling: sqliteUpdateBillingQuery,
},
}, nil
}
if err := initManager(manager); err != nil {
return nil, err
}
return manager, nil
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,713 +0,0 @@
package user_test
import (
"net/netip"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/user"
)
func forEachStoreBackend(t *testing.T, f func(t *testing.T, store user.Store)) {
t.Run("sqlite", func(t *testing.T) {
store, err := user.NewSQLiteStore(filepath.Join(t.TempDir(), "user.db"), "")
require.Nil(t, err)
t.Cleanup(func() { store.Close() })
f(t, store)
})
t.Run("postgres", func(t *testing.T) {
testDB := dbtest.CreateTestPostgres(t)
store, err := user.NewPostgresStore(testDB)
require.Nil(t, err)
f(t, store)
})
}
func TestStoreAddUser(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
require.Equal(t, user.RoleUser, u.Role)
require.False(t, u.Provisioned)
require.NotEmpty(t, u.ID)
require.NotEmpty(t, u.SyncTopic)
})
}
func TestStoreAddUserAlreadyExists(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Equal(t, user.ErrUserExists, store.AddUser("phil", "philhash", user.RoleUser, false))
})
}
func TestStoreRemoveUser(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
require.Nil(t, store.RemoveUser("phil"))
_, err = store.User("phil")
require.Equal(t, user.ErrUserNotFound, err)
})
}
func TestStoreUserByID(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleAdmin, false))
u, err := store.User("phil")
require.Nil(t, err)
u2, err := store.UserByID(u.ID)
require.Nil(t, err)
require.Equal(t, u.Name, u2.Name)
require.Equal(t, u.ID, u2.ID)
})
}
func TestStoreUserByToken(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
tk, err := store.CreateToken(u.ID, "tk_test123", "test token", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(24*time.Hour), 0, false)
require.Nil(t, err)
require.Equal(t, "tk_test123", tk.Value)
u2, err := store.UserByToken(tk.Value)
require.Nil(t, err)
require.Equal(t, "phil", u2.Name)
})
}
func TestStoreUserByStripeCustomer(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.ChangeBilling("phil", &user.Billing{
StripeCustomerID: "cus_test123",
StripeSubscriptionID: "sub_test123",
}))
u, err := store.UserByStripeCustomer("cus_test123")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
require.Equal(t, "cus_test123", u.Billing.StripeCustomerID)
})
}
func TestStoreUsers(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AddUser("ben", "benhash", user.RoleAdmin, false))
users, err := store.Users()
require.Nil(t, err)
require.True(t, len(users) >= 3) // phil, ben, and the everyone user
})
}
func TestStoreUsersCount(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
count, err := store.UsersCount()
require.Nil(t, err)
require.True(t, count >= 1) // At least the everyone user
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
count2, err := store.UsersCount()
require.Nil(t, err)
require.Equal(t, count+1, count2)
})
}
func TestStoreChangePassword(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Equal(t, "philhash", u.Hash)
require.Nil(t, store.ChangePassword("phil", "newhash"))
u, err = store.User("phil")
require.Nil(t, err)
require.Equal(t, "newhash", u.Hash)
})
}
func TestStoreChangeRole(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Equal(t, user.RoleUser, u.Role)
require.Nil(t, store.ChangeRole("phil", user.RoleAdmin))
u, err = store.User("phil")
require.Nil(t, err)
require.Equal(t, user.RoleAdmin, u.Role)
})
}
func TestStoreTokens(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
now := time.Now()
expires := now.Add(24 * time.Hour)
origin := netip.MustParseAddr("9.9.9.9")
tk, err := store.CreateToken(u.ID, "tk_abc", "my token", now, origin, expires, 0, false)
require.Nil(t, err)
require.Equal(t, "tk_abc", tk.Value)
require.Equal(t, "my token", tk.Label)
// Get single token
tk2, err := store.Token(u.ID, "tk_abc")
require.Nil(t, err)
require.Equal(t, "tk_abc", tk2.Value)
require.Equal(t, "my token", tk2.Label)
// Get all tokens
tokens, err := store.Tokens(u.ID)
require.Nil(t, err)
require.Len(t, tokens, 1)
require.Equal(t, "tk_abc", tokens[0].Value)
})
}
func TestStoreTokenChange(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
expires := time.Now().Add(time.Hour)
_, err = store.CreateToken(u.ID, "tk_abc", "old label", time.Now(), netip.MustParseAddr("1.2.3.4"), expires, 0, false)
require.Nil(t, err)
newExpires := time.Now().Add(2 * time.Hour)
require.Nil(t, store.ChangeToken(u.ID, "tk_abc", "new label", newExpires))
tk, err := store.Token(u.ID, "tk_abc")
require.Nil(t, err)
require.Equal(t, "new label", tk.Label)
require.Equal(t, newExpires.Unix(), tk.Expires.Unix())
})
}
func TestStoreTokenRemove(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
_, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), 0, false)
require.Nil(t, err)
require.Nil(t, store.RemoveToken(u.ID, "tk_abc"))
_, err = store.Token(u.ID, "tk_abc")
require.Equal(t, user.ErrTokenNotFound, err)
})
}
func TestStoreTokenRemoveExpired(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
// Create expired token and active token
_, err = store.CreateToken(u.ID, "tk_expired", "expired", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(-time.Hour), 0, false)
require.Nil(t, err)
_, err = store.CreateToken(u.ID, "tk_active", "active", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), 0, false)
require.Nil(t, err)
require.Nil(t, store.RemoveExpiredTokens())
// Expired token should be gone
_, err = store.Token(u.ID, "tk_expired")
require.Equal(t, user.ErrTokenNotFound, err)
// Active token should still exist
tk, err := store.Token(u.ID, "tk_active")
require.Nil(t, err)
require.Equal(t, "tk_active", tk.Value)
})
}
func TestStoreTokenCreatePrunesExcess(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
// Create 2 tokens with no pruning
for i, name := range []string{"tk_a", "tk_b"} {
_, err = store.CreateToken(u.ID, name, name, time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Duration(i+1)*time.Hour), 0, false)
require.Nil(t, err)
}
// Create a 3rd token with maxTokenCount=2, which should prune tk_a (earliest expiry)
_, err = store.CreateToken(u.ID, "tk_c", "tk_c", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(3*time.Hour), 2, false)
require.Nil(t, err)
tokens, err := store.Tokens(u.ID)
require.Nil(t, err)
require.Equal(t, 2, len(tokens))
// tk_a should be removed (earliest expiry)
_, err = store.Token(u.ID, "tk_a")
require.Equal(t, user.ErrTokenNotFound, err)
// tk_b and tk_c should remain
_, err = store.Token(u.ID, "tk_b")
require.Nil(t, err)
_, err = store.Token(u.ID, "tk_c")
require.Nil(t, err)
})
}
func TestStoreTokenUpdateLastAccess(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
_, err = store.CreateToken(u.ID, "tk_abc", "label", time.Now(), netip.MustParseAddr("1.2.3.4"), time.Now().Add(time.Hour), 0, false)
require.Nil(t, err)
newTime := time.Now().Add(5 * time.Minute)
newOrigin := netip.MustParseAddr("5.5.5.5")
require.Nil(t, store.UpdateTokenLastAccess(map[string]*user.TokenUpdate{"tk_abc": {LastAccess: newTime, LastOrigin: newOrigin}}))
tk, err := store.Token(u.ID, "tk_abc")
require.Nil(t, err)
require.Equal(t, newTime.Unix(), tk.LastAccess.Unix())
require.Equal(t, newOrigin, tk.LastOrigin)
})
}
func TestStoreAllowAccess(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "", false))
grants, err := store.Grants("phil")
require.Nil(t, err)
require.Len(t, grants, 1)
require.Equal(t, "mytopic", grants[0].TopicPattern)
require.True(t, grants[0].Permission.IsReadWrite())
})
}
func TestStoreAllowAccessReadOnly(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "announcements", true, false, "", false))
grants, err := store.Grants("phil")
require.Nil(t, err)
require.Len(t, grants, 1)
require.True(t, grants[0].Permission.IsRead())
require.False(t, grants[0].Permission.IsWrite())
})
}
func TestStoreResetAccess(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false))
require.Nil(t, store.AllowAccess("phil", "topic2", true, false, "", false))
grants, err := store.Grants("phil")
require.Nil(t, err)
require.Len(t, grants, 2)
require.Nil(t, store.ResetAccess("phil", "topic1"))
grants, err = store.Grants("phil")
require.Nil(t, err)
require.Len(t, grants, 1)
require.Equal(t, "topic2", grants[0].TopicPattern)
})
}
func TestStoreResetAccessAll(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false))
require.Nil(t, store.AllowAccess("phil", "topic2", true, false, "", false))
require.Nil(t, store.ResetAccess("phil", ""))
grants, err := store.Grants("phil")
require.Nil(t, err)
require.Len(t, grants, 0)
})
}
func TestStoreAuthorizeTopicAccess(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "", false))
read, write, found, err := store.AuthorizeTopicAccess("phil", "mytopic")
require.Nil(t, err)
require.True(t, found)
require.True(t, read)
require.True(t, write)
})
}
func TestStoreAuthorizeTopicAccessNotFound(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
_, _, found, err := store.AuthorizeTopicAccess("phil", "other")
require.Nil(t, err)
require.False(t, found)
})
}
func TestStoreAuthorizeTopicAccessDenyAll(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "secret", false, false, "", false))
read, write, found, err := store.AuthorizeTopicAccess("phil", "secret")
require.Nil(t, err)
require.True(t, found)
require.False(t, read)
require.False(t, write)
})
}
func TestStoreReservations(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false))
require.Nil(t, store.AllowAccess(user.Everyone, "mytopic", true, false, "phil", false))
reservations, err := store.Reservations("phil")
require.Nil(t, err)
require.Len(t, reservations, 1)
require.Equal(t, "mytopic", reservations[0].Topic)
require.True(t, reservations[0].Owner.IsReadWrite())
require.True(t, reservations[0].Everyone.IsRead())
require.False(t, reservations[0].Everyone.IsWrite())
})
}
func TestStoreReservationsCount(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "phil", false))
require.Nil(t, store.AllowAccess("phil", "topic2", true, true, "phil", false))
count, err := store.ReservationsCount("phil")
require.Nil(t, err)
require.Equal(t, int64(2), count)
})
}
func TestStoreHasReservation(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false))
has, err := store.HasReservation("phil", "mytopic")
require.Nil(t, err)
require.True(t, has)
has, err = store.HasReservation("phil", "other")
require.Nil(t, err)
require.False(t, has)
})
}
func TestStoreReservationOwner(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("phil", "mytopic", true, true, "phil", false))
owner, err := store.ReservationOwner("mytopic")
require.Nil(t, err)
require.NotEmpty(t, owner) // Returns the user ID
owner, err = store.ReservationOwner("unowned")
require.Nil(t, err)
require.Empty(t, owner)
})
}
func TestStoreTiers(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
tier := &user.Tier{
ID: "ti_test",
Code: "pro",
Name: "Pro",
MessageLimit: 5000,
MessageExpiryDuration: 24 * time.Hour,
EmailLimit: 100,
CallLimit: 10,
ReservationLimit: 20,
AttachmentFileSizeLimit: 10 * 1024 * 1024,
AttachmentTotalSizeLimit: 100 * 1024 * 1024,
AttachmentExpiryDuration: 48 * time.Hour,
AttachmentBandwidthLimit: 500 * 1024 * 1024,
}
require.Nil(t, store.AddTier(tier))
// Get by code
t2, err := store.Tier("pro")
require.Nil(t, err)
require.Equal(t, "ti_test", t2.ID)
require.Equal(t, "pro", t2.Code)
require.Equal(t, "Pro", t2.Name)
require.Equal(t, int64(5000), t2.MessageLimit)
require.Equal(t, int64(100), t2.EmailLimit)
require.Equal(t, int64(10), t2.CallLimit)
require.Equal(t, int64(20), t2.ReservationLimit)
// List all tiers
tiers, err := store.Tiers()
require.Nil(t, err)
require.Len(t, tiers, 1)
require.Equal(t, "pro", tiers[0].Code)
})
}
func TestStoreTierUpdate(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
tier := &user.Tier{
ID: "ti_test",
Code: "pro",
Name: "Pro",
}
require.Nil(t, store.AddTier(tier))
tier.Name = "Professional"
tier.MessageLimit = 9999
require.Nil(t, store.UpdateTier(tier))
t2, err := store.Tier("pro")
require.Nil(t, err)
require.Equal(t, "Professional", t2.Name)
require.Equal(t, int64(9999), t2.MessageLimit)
})
}
func TestStoreTierRemove(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
tier := &user.Tier{
ID: "ti_test",
Code: "pro",
Name: "Pro",
}
require.Nil(t, store.AddTier(tier))
t2, err := store.Tier("pro")
require.Nil(t, err)
require.Equal(t, "pro", t2.Code)
require.Nil(t, store.RemoveTier("pro"))
_, err = store.Tier("pro")
require.Equal(t, user.ErrTierNotFound, err)
})
}
func TestStoreTierByStripePrice(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
tier := &user.Tier{
ID: "ti_test",
Code: "pro",
Name: "Pro",
StripeMonthlyPriceID: "price_monthly",
StripeYearlyPriceID: "price_yearly",
}
require.Nil(t, store.AddTier(tier))
t2, err := store.TierByStripePrice("price_monthly")
require.Nil(t, err)
require.Equal(t, "pro", t2.Code)
t3, err := store.TierByStripePrice("price_yearly")
require.Nil(t, err)
require.Equal(t, "pro", t3.Code)
})
}
func TestStoreChangeTier(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
tier := &user.Tier{
ID: "ti_test",
Code: "pro",
Name: "Pro",
}
require.Nil(t, store.AddTier(tier))
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.ChangeTier("phil", "pro"))
u, err := store.User("phil")
require.Nil(t, err)
require.NotNil(t, u.Tier)
require.Equal(t, "pro", u.Tier.Code)
})
}
func TestStorePhoneNumbers(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Nil(t, store.AddPhoneNumber(u.ID, "+1234567890"))
require.Nil(t, store.AddPhoneNumber(u.ID, "+0987654321"))
numbers, err := store.PhoneNumbers(u.ID)
require.Nil(t, err)
require.Len(t, numbers, 2)
require.Nil(t, store.RemovePhoneNumber(u.ID, "+1234567890"))
numbers, err = store.PhoneNumbers(u.ID)
require.Nil(t, err)
require.Len(t, numbers, 1)
require.Equal(t, "+0987654321", numbers[0])
})
}
func TestStoreChangeSettings(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
lang := "de"
prefs := &user.Prefs{Language: &lang}
require.Nil(t, store.ChangeSettings(u.ID, prefs))
u2, err := store.User("phil")
require.Nil(t, err)
require.NotNil(t, u2.Prefs)
require.Equal(t, "de", *u2.Prefs.Language)
})
}
func TestStoreChangeBilling(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
billing := &user.Billing{
StripeCustomerID: "cus_123",
StripeSubscriptionID: "sub_456",
}
require.Nil(t, store.ChangeBilling("phil", billing))
u, err := store.User("phil")
require.Nil(t, err)
require.Equal(t, "cus_123", u.Billing.StripeCustomerID)
require.Equal(t, "sub_456", u.Billing.StripeSubscriptionID)
})
}
func TestStoreUpdateStats(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
stats := &user.Stats{Messages: 42, Emails: 3, Calls: 1}
require.Nil(t, store.UpdateStats(map[string]*user.Stats{u.ID: stats}))
u2, err := store.User("phil")
require.Nil(t, err)
require.Equal(t, int64(42), u2.Stats.Messages)
require.Equal(t, int64(3), u2.Stats.Emails)
require.Equal(t, int64(1), u2.Stats.Calls)
})
}
func TestStoreResetStats(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Nil(t, store.UpdateStats(map[string]*user.Stats{u.ID: {Messages: 42, Emails: 3, Calls: 1}}))
require.Nil(t, store.ResetStats())
u2, err := store.User("phil")
require.Nil(t, err)
require.Equal(t, int64(0), u2.Stats.Messages)
require.Equal(t, int64(0), u2.Stats.Emails)
require.Equal(t, int64(0), u2.Stats.Calls)
})
}
func TestStoreMarkUserRemoved(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Nil(t, store.MarkUserRemoved(u.ID, u.Name))
u2, err := store.User("phil")
require.Nil(t, err)
require.True(t, u2.Deleted)
})
}
func TestStoreRemoveDeletedUsers(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
u, err := store.User("phil")
require.Nil(t, err)
require.Nil(t, store.MarkUserRemoved(u.ID, u.Name))
// RemoveDeletedUsers only removes users past the hard-delete duration (7 days).
// Immediately after marking, the user should still exist.
require.Nil(t, store.RemoveDeletedUsers())
u2, err := store.User("phil")
require.Nil(t, err)
require.True(t, u2.Deleted)
})
}
func TestStoreAllGrants(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AddUser("ben", "benhash", user.RoleUser, false))
phil, err := store.User("phil")
require.Nil(t, err)
ben, err := store.User("ben")
require.Nil(t, err)
require.Nil(t, store.AllowAccess("phil", "topic1", true, true, "", false))
require.Nil(t, store.AllowAccess("ben", "topic2", true, false, "", false))
grants, err := store.AllGrants()
require.Nil(t, err)
require.Contains(t, grants, phil.ID)
require.Contains(t, grants, ben.ID)
})
}
func TestStoreOtherAccessCount(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, store user.Store) {
require.Nil(t, store.AddUser("phil", "philhash", user.RoleUser, false))
require.Nil(t, store.AddUser("ben", "benhash", user.RoleUser, false))
require.Nil(t, store.AllowAccess("ben", "mytopic", true, true, "ben", false))
count, err := store.OtherAccessCount("phil", "mytopic")
require.Nil(t, err)
require.Equal(t, 1, count)
})
}

View File

@@ -1,12 +1,14 @@
package user
import (
"database/sql"
"errors"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/payments"
"net/netip"
"strings"
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/payments"
)
// User is a struct that represents a user
@@ -273,3 +275,78 @@ var (
ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user")
ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
)
// storeQueries holds the database-specific SQL queries
type storeQueries struct {
// User queries
selectUserByID string
selectUserByName string
selectUserByToken string
selectUserByStripeCustomerID string
selectUsernames string
selectUserCount string
selectUserIDFromUsername string
insertUser string
updateUserPass string
updateUserRole string
updateUserProvisioned string
updateUserPrefs string
updateUserStats string
updateUserStatsResetAll string
updateUserTier string
updateUserDeleted string
deleteUser string
deleteUserTier string
deleteUsersMarked string
// Access queries
selectTopicPerms string
selectUserAllAccess string
selectUserAccess string
selectUserReservations string
selectUserReservationsCount string
selectUserReservationsOwner string
selectUserHasReservation string
selectOtherAccessCount string
upsertUserAccess string
deleteUserAccess string
deleteUserAccessProvisioned string
deleteTopicAccess string
deleteAllAccess string
// Token queries
selectToken string
selectTokens string
selectTokenCount string
selectAllProvisionedTokens string
upsertToken string
updateToken string
updateTokenLastAccess string
deleteToken string
deleteProvisionedToken string
deleteAllToken string
deleteExpiredTokens string
deleteExcessTokens string
// Tier queries
insertTier string
selectTiers string
selectTierByCode string
selectTierByPriceID string
updateTier string
deleteTier string
// Phone queries
selectPhoneNumbers string
insertPhoneNumber string
deletePhoneNumber string
// Billing queries
updateBilling string
}
// execer is satisfied by both *sql.DB and *sql.Tx, allowing helper methods
// to be used both standalone and within a transaction.
type execer interface {
Exec(query string, args ...any) (sql.Result, error)
}