mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-11-10 22:50:59 +08:00
accessCache now owns everything about reloading: Reload (synchronous, reports changes to the callback after releasing reloadMu), RequestReload (coalesced, at most one pending), the periodic loop, and Close. The Manager only tells the peers and forwards.
411 lines
15 KiB
Go
411 lines
15 KiB
Go
package user
|
|
|
|
import (
|
|
"regexp"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// Cache-only unit tests. Integration with the Manager (loading from the DB,
|
|
// reload-after-mutation, end-to-end Authorize behavior) is covered by the
|
|
// existing TestStoreAuthorizeTopicAccess* tests in manager_test.go via
|
|
// forEachStoreBackend.
|
|
|
|
func TestCompileLikeToRegex_Exact(t *testing.T) {
|
|
r := mustCompileLikeToRegex(t, "foo")
|
|
require.True(t, r.MatchString("foo"))
|
|
require.False(t, r.MatchString("foox"))
|
|
require.False(t, r.MatchString("xfoo"))
|
|
}
|
|
|
|
func TestCompileLikeToRegex_TrailingPercent(t *testing.T) {
|
|
r := mustCompileLikeToRegex(t, "up%")
|
|
require.True(t, r.MatchString("up"))
|
|
require.True(t, r.MatchString("up123"))
|
|
require.False(t, r.MatchString("xup"))
|
|
}
|
|
|
|
func TestCompileLikeToRegex_LeadingAndEmbeddedPercent(t *testing.T) {
|
|
r := mustCompileLikeToRegex(t, "%test%")
|
|
require.True(t, r.MatchString("test"))
|
|
require.True(t, r.MatchString("mytest"))
|
|
require.True(t, r.MatchString("testxxx"))
|
|
require.True(t, r.MatchString("xtestx"))
|
|
require.False(t, r.MatchString("nope"))
|
|
}
|
|
|
|
func TestCompileLikeToRegex_EscapedUnderscore(t *testing.T) {
|
|
// "my\_topic" is the stored form of a literal "my_topic" -- the underscore
|
|
// must match itself, NOT act as a SQL one-character wildcard.
|
|
r := mustCompileLikeToRegex(t, `my\_topic`)
|
|
require.True(t, r.MatchString("my_topic"))
|
|
require.False(t, r.MatchString("myXtopic"))
|
|
require.False(t, r.MatchString("mytopic"))
|
|
}
|
|
|
|
func TestCompileLikeToRegex_EscapedUnderscoreAdjacentToPercent(t *testing.T) {
|
|
// "nz\_vip\_%" is the stored form of "nz_vip_*" -- literal "nz_vip_" prefix
|
|
// followed by any suffix.
|
|
r := mustCompileLikeToRegex(t, `nz\_vip\_%`)
|
|
require.True(t, r.MatchString("nz_vip_"))
|
|
require.True(t, r.MatchString("nz_vip_alpha"))
|
|
require.False(t, r.MatchString("nz_vipX"))
|
|
require.False(t, r.MatchString("nzvip_alpha"))
|
|
}
|
|
|
|
func TestCompileLikeToRegex_RegexMetaCharsInTopic(t *testing.T) {
|
|
// Topics in ntfy can include '-', which is benign, but make sure
|
|
// regex metacharacters in the pattern are escaped properly anyway.
|
|
r := mustCompileLikeToRegex(t, "foo-bar")
|
|
require.True(t, r.MatchString("foo-bar"))
|
|
require.False(t, r.MatchString("foo.bar")) // would match if '-' leaked into a character class
|
|
}
|
|
|
|
func TestACLCache_LookupBeforeReload(t *testing.T) {
|
|
// A freshly-constructed cache has empty exact and wildcards maps. The
|
|
// cache treats this as "no rule found", which the caller resolves via
|
|
// DefaultAccess.
|
|
c := newAccessCache(nil, "", nil) // Never reloaded: these tests fill the maps themselves
|
|
read, write, found := c.Lookup("phil", "mytopic")
|
|
require.False(t, found)
|
|
require.False(t, read)
|
|
require.False(t, write)
|
|
}
|
|
|
|
func TestACLCache_ExactMatchHit(t *testing.T) {
|
|
c := newAccessCache(nil, "", nil) // Never reloaded: these tests fill the maps themselves
|
|
loadCache(t, c, []rawACLRow{
|
|
{user: "phil", topic: "mytopic", read: true, write: true},
|
|
})
|
|
read, write, found := c.Lookup("phil", "mytopic")
|
|
require.True(t, found)
|
|
require.True(t, read)
|
|
require.True(t, write)
|
|
}
|
|
|
|
func TestACLCache_ExactMatchMiss(t *testing.T) {
|
|
c := newAccessCache(nil, "", nil) // Never reloaded: these tests fill the maps themselves
|
|
loadCache(t, c, []rawACLRow{
|
|
{user: "phil", topic: "mytopic", read: true, write: true},
|
|
})
|
|
_, _, found := c.Lookup("phil", "othertopic")
|
|
require.False(t, found)
|
|
}
|
|
|
|
func TestACLCache_LiteralUnderscoreExactMatch(t *testing.T) {
|
|
// Stored as "my\_topic" (toSQLWildcard of "my_topic"). A literal underscore
|
|
// in the requested topic must match, while any other single char must not.
|
|
c := newAccessCache(nil, "", nil) // Never reloaded: these tests fill the maps themselves
|
|
loadCache(t, c, []rawACLRow{
|
|
{user: "phil", topic: `my\_topic`, read: true, write: false},
|
|
})
|
|
read, write, found := c.Lookup("phil", "my_topic")
|
|
require.True(t, found)
|
|
require.True(t, read)
|
|
require.False(t, write)
|
|
|
|
_, _, found = c.Lookup("phil", "myXtopic")
|
|
require.False(t, found)
|
|
}
|
|
|
|
func TestACLCache_WildcardMatch(t *testing.T) {
|
|
c := newAccessCache(nil, "", nil) // Never reloaded: these tests fill the maps themselves
|
|
loadCache(t, c, []rawACLRow{
|
|
{user: Everyone, topic: "up%", read: false, write: true},
|
|
})
|
|
read, write, found := c.Lookup("phil", "up42")
|
|
require.True(t, found)
|
|
require.False(t, read)
|
|
require.True(t, write)
|
|
}
|
|
|
|
func TestACLCache_SpecificUserBeatsEveryone(t *testing.T) {
|
|
c := newAccessCache(nil, "", nil) // Never reloaded: these tests fill the maps themselves
|
|
loadCache(t, c, []rawACLRow{
|
|
{user: Everyone, topic: "mytopic", read: true, write: false},
|
|
{user: "phil", topic: "mytopic", read: false, write: false}, // deny-all for phil
|
|
})
|
|
read, write, found := c.Lookup("phil", "mytopic")
|
|
require.True(t, found)
|
|
require.False(t, read)
|
|
require.False(t, write)
|
|
}
|
|
|
|
func TestACLCache_SpecificUserBeatsEveryoneEvenWhenShorter(t *testing.T) {
|
|
// The SQL's "user_name DESC" sort key takes precedence over LENGTH(topic).
|
|
// Concretely: a specific user with a shorter matching rule still wins over
|
|
// Everyone with a longer matching rule.
|
|
c := newAccessCache(nil, "", nil) // Never reloaded: these tests fill the maps themselves
|
|
loadCache(t, c, []rawACLRow{
|
|
{user: Everyone, topic: "foo", read: true, write: true}, // exact, length 3
|
|
{user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all
|
|
})
|
|
read, write, found := c.Lookup("phil", "foo")
|
|
require.True(t, found)
|
|
require.False(t, read)
|
|
require.False(t, write)
|
|
}
|
|
|
|
func TestACLCache_SpecificUserBeatsEveryoneRegardlessOfWrite(t *testing.T) {
|
|
// Same-length rules but conflicting permissions across user boundary: the
|
|
// specific user always wins, even if its permission set is weaker (or
|
|
// stronger, in either direction).
|
|
c := newAccessCache(nil, "", nil) // Never reloaded: these tests fill the maps themselves
|
|
loadCache(t, c, []rawACLRow{
|
|
{user: Everyone, topic: "mytopic", read: true, write: true}, // wide-open
|
|
{user: "phil", topic: "mytopic", read: true, write: false}, // read-only for phil
|
|
})
|
|
read, write, found := c.Lookup("phil", "mytopic")
|
|
require.True(t, found)
|
|
require.True(t, read)
|
|
require.False(t, write)
|
|
}
|
|
|
|
func TestACLCache_AnonymousReadsEveryone(t *testing.T) {
|
|
c := newAccessCache(nil, "", nil) // Never reloaded: these tests fill the maps themselves
|
|
loadCache(t, c, []rawACLRow{
|
|
{user: Everyone, topic: "announcements", read: true, write: false},
|
|
})
|
|
read, write, found := c.Lookup(Everyone, "announcements")
|
|
require.True(t, found)
|
|
require.True(t, read)
|
|
require.False(t, write)
|
|
}
|
|
|
|
func TestACLCache_LongerPatternWinsForSameUser(t *testing.T) {
|
|
// Both rules belong to the same user (Everyone). The more specific (longer)
|
|
// "mytopic%" should beat the catch-all "%".
|
|
c := newAccessCache(nil, "", nil) // Never reloaded: these tests fill the maps themselves
|
|
loadCache(t, c, []rawACLRow{
|
|
{user: Everyone, topic: "%", read: true, write: false},
|
|
{user: Everyone, topic: "mytopic%", read: true, write: true},
|
|
})
|
|
read, write, found := c.Lookup(Everyone, "mytopicX")
|
|
require.True(t, found)
|
|
require.True(t, read)
|
|
require.True(t, write)
|
|
}
|
|
|
|
func TestACLCache_ExactBeatsShorterWildcardSameUser(t *testing.T) {
|
|
// Same user, two matching rules: exact "foo" (length 3) and wildcard "f%"
|
|
// (length 2). The longer one wins, which is the exact rule -- mirroring
|
|
// the SQL's "LENGTH(topic) DESC" tie-break. Crucially, the cache must seed
|
|
// "best" from the exact map probe before walking wildcards, otherwise a
|
|
// shorter wildcard could overwrite a longer exact.
|
|
c := newAccessCache(nil, "", nil) // Never reloaded: these tests fill the maps themselves
|
|
loadCache(t, c, []rawACLRow{
|
|
{user: "phil", topic: "foo", read: true, write: true}, // exact, length 3
|
|
{user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all
|
|
})
|
|
read, write, found := c.Lookup("phil", "foo")
|
|
require.True(t, found)
|
|
require.True(t, read)
|
|
require.True(t, write)
|
|
}
|
|
|
|
func TestACLCache_LongerWildcardBeatsExactSameUser(t *testing.T) {
|
|
// Same user, two matching rules: exact "foo" (length 3) and wildcard "foo%"
|
|
// (length 4). The wildcard wins on length DESC. Exercises the "swap best
|
|
// to wildcard when better() returns true" path.
|
|
c := newAccessCache(nil, "", nil) // Never reloaded: these tests fill the maps themselves
|
|
loadCache(t, c, []rawACLRow{
|
|
{user: "phil", topic: "foo", read: false, write: false}, // exact, length 3, deny-all
|
|
{user: "phil", topic: "foo%", read: true, write: true}, // wildcard, length 4
|
|
})
|
|
read, write, found := c.Lookup("phil", "foo")
|
|
require.True(t, found)
|
|
require.True(t, read)
|
|
require.True(t, write)
|
|
}
|
|
|
|
func TestACLCache_WriteBeatsReadAtEqualLength(t *testing.T) {
|
|
// Two wildcard rules of identical length for the same user. The write rule
|
|
// should win the tie-break. The two-rows-with-same-topic shape is
|
|
// impossible via real upsert (pkey would conflict), so we inject the entries
|
|
// directly into the cache's wildcard slice.
|
|
c := newAccessCache(nil, "", nil) // Never reloaded: these tests fill the maps themselves
|
|
c.mu.Lock()
|
|
c.exact = map[string]map[string]aclEntry{}
|
|
c.pattern = map[string][]aclEntry{
|
|
Everyone: {
|
|
{length: len("ab%"), read: true, write: false, pattern: mustCompileLikeToRegex(t, "ab%")},
|
|
{length: len("ab%"), read: false, write: true, pattern: mustCompileLikeToRegex(t, "ab%")},
|
|
},
|
|
}
|
|
c.mu.Unlock()
|
|
_, write, found := c.Lookup(Everyone, "abc")
|
|
require.True(t, found)
|
|
require.True(t, write)
|
|
}
|
|
|
|
func TestACLCache_ConcurrentLookupAndReload(t *testing.T) {
|
|
// Lock-based swap must be safe under concurrent reads. The race detector
|
|
// catches any unsafe shared mutation.
|
|
c := newAccessCache(nil, "", nil) // Never reloaded: these tests fill the maps themselves
|
|
loadCache(t, c, []rawACLRow{
|
|
{user: Everyone, topic: "mytopic", read: true, write: true},
|
|
})
|
|
|
|
var stop atomic.Bool
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
for !stop.Load() {
|
|
_, _, _ = c.Lookup(Everyone, "mytopic")
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < 100; i++ {
|
|
loadCache(t, c, []rawACLRow{
|
|
{user: Everyone, topic: "mytopic", read: i%2 == 0, write: i%2 == 1},
|
|
})
|
|
}
|
|
stop.Store(true)
|
|
}()
|
|
wg.Wait()
|
|
}
|
|
|
|
// rawACLRow models the rows that reload would Scan from the DB but avoids
|
|
// actually opening a DB for these unit tests.
|
|
type rawACLRow struct {
|
|
user string
|
|
topic string
|
|
read bool
|
|
write bool
|
|
}
|
|
|
|
// loadCache writes the given rows into the cache under its write lock,
|
|
// preserving the same exact/wildcard partitioning that reload would produce.
|
|
func loadCache(t *testing.T, c *accessCache, rows []rawACLRow) {
|
|
t.Helper()
|
|
exact := make(map[string]map[string]aclEntry)
|
|
wildcards := make(map[string][]aclEntry)
|
|
for _, r := range rows {
|
|
e, hasWildcard, err := toACLEntry(r.topic, r.read, r.write) // As Reload builds them
|
|
require.Nil(t, err)
|
|
if hasWildcard {
|
|
wildcards[r.user] = append(wildcards[r.user], e)
|
|
} else {
|
|
if exact[r.user] == nil {
|
|
exact[r.user] = make(map[string]aclEntry)
|
|
}
|
|
exact[r.user][r.topic] = e
|
|
}
|
|
}
|
|
c.mu.Lock()
|
|
c.exact = exact
|
|
c.pattern = wildcards
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func mustCompileLikeToRegex(t *testing.T, pattern string) *regexp.Regexp {
|
|
t.Helper()
|
|
r, err := compileLikeToRegex(pattern)
|
|
require.NoError(t, err)
|
|
return r
|
|
}
|
|
|
|
func TestACLCache_DiffACLEntries(t *testing.T) {
|
|
// Added, removed and changed entries are reported, exact and wildcard alike, per user; unchanged
|
|
// ones are not. Wildcard topics come back with * (as passed to AllowAccess).
|
|
before := newAccessCache(nil, "", nil) // Never reloaded: the test fills the maps itself
|
|
loadCache(t, before, []rawACLRow{
|
|
{user: "phil", topic: "unchanged", read: true, write: true},
|
|
{user: "phil", topic: "removed", read: true, write: false},
|
|
{user: "phil", topic: "downgraded", read: true, write: true},
|
|
{user: "phil", topic: "up\\_%", read: true, write: false},
|
|
{user: "ben", topic: "shared", read: true, write: false},
|
|
{user: "*", topic: "public", read: true, write: true},
|
|
})
|
|
after := newAccessCache(nil, "", nil)
|
|
loadCache(t, after, []rawACLRow{
|
|
{user: "phil", topic: "unchanged", read: true, write: true},
|
|
{user: "phil", topic: "downgraded", read: true, write: false},
|
|
{user: "phil", topic: "added", read: true, write: false},
|
|
{user: "ben", topic: "shared", read: true, write: false},
|
|
{user: "ben", topic: "new%", read: true, write: true},
|
|
{user: "*", topic: "public", read: false, write: false},
|
|
})
|
|
require.ElementsMatch(t, []*AccessChange{
|
|
{
|
|
Username: "phil",
|
|
TopicPattern: "removed",
|
|
},
|
|
{
|
|
Username: "phil",
|
|
TopicPattern: "downgraded",
|
|
},
|
|
{
|
|
Username: "phil",
|
|
TopicPattern: "up_*",
|
|
},
|
|
{
|
|
Username: "phil",
|
|
TopicPattern: "added",
|
|
},
|
|
{
|
|
Username: "ben",
|
|
TopicPattern: "new*",
|
|
},
|
|
{
|
|
Username: "*",
|
|
TopicPattern: "public",
|
|
},
|
|
}, diffACLEntries(before.exact, before.pattern, after.exact, after.pattern))
|
|
|
|
// Nothing changed, nothing reported
|
|
require.Empty(t, diffACLEntries(after.exact, after.pattern, after.exact, after.pattern))
|
|
}
|
|
|
|
func TestAccessCache_RequestReloadCoalesces(t *testing.T) {
|
|
// A burst of "access changed" signals from peers must not queue a reload each: requests that
|
|
// arrive during a reload collapse into one more, which still sees a write made during the first
|
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
|
a := newTestManagerFromConfig(t, newManager, &Config{
|
|
DefaultAccess: PermissionDenyAll,
|
|
BcryptCost: bcrypt.MinCost,
|
|
AccessCacheEnabled: true,
|
|
AccessCacheReloadInterval: time.Hour, // keep the background poller out of this test
|
|
})
|
|
require.Nil(t, a.AddUser("phil", "mypass", RoleUser, false))
|
|
reloads := atomic.Int32{}
|
|
scanned, release := make(chan struct{}), make(chan struct{})
|
|
testHookReloadScanned = func() {
|
|
if reloads.Add(1) == 1 {
|
|
close(scanned)
|
|
<-release // Hold the first reload after its scan
|
|
}
|
|
}
|
|
defer func() { testHookReloadScanned = nil }()
|
|
|
|
// Start a reload and hold it after it scanned the DB
|
|
a.accessCache.RequestReload()
|
|
<-scanned
|
|
|
|
// A write the held reload did not see, as if made on a peer, then a burst of signals for it
|
|
_, err := a.db.Exec(a.queries.upsertUserAccess, "phil", "secret", true, true, "", "", false)
|
|
require.Nil(t, err)
|
|
start := time.Now()
|
|
for i := 0; i < 100; i++ {
|
|
a.accessCache.RequestReload()
|
|
}
|
|
require.Less(t, time.Since(start), 50*time.Millisecond) // Requests never wait for a reload
|
|
close(release)
|
|
|
|
// The burst collapses into one more reload, which sees the write
|
|
require.Eventually(t, func() bool {
|
|
_, _, found, err := a.authorizeTopicAccess("phil", "secret")
|
|
return err == nil && found
|
|
}, 5*time.Second, 10*time.Millisecond)
|
|
time.Sleep(200 * time.Millisecond) // Let any queued reload finish
|
|
require.Equal(t, int32(2), reloads.Load())
|
|
})
|
|
}
|