mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Fixes all 109 golangci-lint findings (106 contextcheck, 1 gocritic,
2 gosec) that accumulated after D2 wired dbgen (whose queries take ctx)
under ctx-less db.DB wrappers while CI lint was quota-dead. No nolint
comments added; every finding fixed by genuinely threading context.
- db: all 138 hand-written db.DB methods take ctx first; the dbCtx()
Background shim is deleted; raw Query/QueryRow/Exec/Begin use their
Context variants; the four redundant ctx-less passthroughs removed.
db.Auditor/WriteAudit gain ctx.
- Seams: permissions.Checker (DB iface, HasChannelPerm,
RequireChannelAccess) and the service.Store interface mirror the new
signatures (ws.EventStore and plugin.PluginStore already did).
- Callers: api/admin handlers use r.Context(); ws per-message paths use
the connection ctx via DispatchV2; hub loops and startup wiring use
context.Background(); service methods thread ctx where they have one
and Background where no ctx exists. Public service surface reached by
ctx-holding chains (PermissionService.HasChannelPerm/GetRoleForUser/
RequireChannelAccess, message/dm/block/invite/profile methods) is now
ctx-first.
- Detached (context.WithoutCancel) where cancellation would break an
invariant, found by a 3-lens adversarial review of the diff:
* voice-leave background retries (a dead webhook/connection ctx killed
retry 2 before it ran, leaving ghost capacity-holding voice rows)
* rollbackVoiceJoin's compensating delete (its trigger IS the cancel)
* post-2FA-change DeleteOtherSessions and logout DeleteSession (the
security tail of a committed change must not die with the request)
* all api/ws audit writes (a banned user could suppress their own
login_blocked_banned row by aborting the request mid-bcrypt)
* admin backup VACUUM INTO (an interrupt left a truncated .db that
the backup list presented as restorable)
* post-commit message/edit refetches (a committed message must still
fan out when the sender disconnects)
* hub settings-cache refresh (one dead connection could pin stale
values for the 30s TTL)
- gocritic rangeValCopy fixed (index iteration); gosec G306 excluded in
config with justification (generated source must stay world-readable)
instead of flipping genprotocol output to 0o600.
Verified: gofmt/vet, all four build-tag variants, full suite, deadlock
pass, full -race pass, golangci-lint 0 issues uncapped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
324 lines
12 KiB
Go
324 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
)
|
|
|
|
// errOverrideStore wraps a real *db.DB but always fails the channel-override
|
|
// fetch, so the fail-closed contract (A-2026-07-16) is testable. Embedding
|
|
// *db.DB satisfies the service Store interface; only the one overridden method
|
|
// diverges, every other call still hits the real database.
|
|
type errOverrideStore struct {
|
|
*db.DB
|
|
}
|
|
|
|
func (errOverrideStore) GetAllChannelPermissionsForRole(context.Context, int64) (map[int64]db.ChannelOverride, error) {
|
|
return nil, errors.New("boom")
|
|
}
|
|
|
|
// TestHasChannelPerm_OverrideFetchErrorDenies locks the fail-closed rule: when
|
|
// the override fetch errors we must NOT substitute an empty map, because that
|
|
// restores every bit a channel-level deny had stripped — and PermissionService
|
|
// would then cache that degraded snapshot for permCacheTTL.
|
|
func TestHasChannelPerm_OverrideFetchErrorDenies(t *testing.T) {
|
|
database := newTestDB(t)
|
|
seedRole(t, database, &db.Role{
|
|
ID: permissions.MemberRoleID,
|
|
Name: "member",
|
|
Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AddReactions,
|
|
Position: 1,
|
|
})
|
|
seedUserRole(t, database, 1, permissions.MemberRoleID)
|
|
seedChannel(t, database, &db.Channel{ID: 10, Name: "readonly", Type: "text"})
|
|
seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.ReadMessages)
|
|
|
|
svc := NewPermissionService(errOverrideStore{DB: database}, permissions.NewChecker(database))
|
|
|
|
if svc.HasChannelPerm(context.Background(), 1, 10, permissions.ReadMessages) {
|
|
t.Fatal("override fetch failure must deny, not fall back to the base role bits")
|
|
}
|
|
}
|
|
|
|
// newTestPermService creates a PermissionService backed by a real in-memory DB
|
|
// pre-populated with a single role and user.
|
|
func newTestPermService(t *testing.T) (*PermissionService, *db.DB) {
|
|
t.Helper()
|
|
database := newTestDB(t)
|
|
seedRole(t, database, &db.Role{
|
|
ID: permissions.MemberRoleID,
|
|
Name: "member",
|
|
Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AddReactions,
|
|
Position: 1,
|
|
})
|
|
seedUserRole(t, database, 1, permissions.MemberRoleID)
|
|
checker := permissions.NewChecker(database)
|
|
return NewPermissionService(database, checker), database
|
|
}
|
|
|
|
func TestHasChannelPerm_Allowed(t *testing.T) {
|
|
svc, database := newTestPermService(t)
|
|
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
|
|
|
|
// Member has SendMessages | ReadMessages; no overrides exist, so base role perms apply.
|
|
if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) {
|
|
t.Fatal("expected user to have SendMessages permission")
|
|
}
|
|
if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ReadMessages) {
|
|
t.Fatal("expected user to have ReadMessages permission")
|
|
}
|
|
}
|
|
|
|
func TestHasChannelPerm_Denied(t *testing.T) {
|
|
svc, database := newTestPermService(t)
|
|
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
|
|
|
|
// ManageMessages is NOT in the member role.
|
|
if svc.HasChannelPerm(context.Background(), 1, 10, permissions.ManageMessages) {
|
|
t.Fatal("expected user to NOT have ManageMessages permission")
|
|
}
|
|
}
|
|
|
|
func TestHasChannelPerm_OverrideDeny(t *testing.T) {
|
|
svc, database := newTestPermService(t)
|
|
seedChannel(t, database, &db.Channel{ID: 10, Name: "readonly", Type: "text"})
|
|
// Deny SendMessages for this channel.
|
|
seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages)
|
|
// Invalidate so next check re-populates cache.
|
|
svc.InvalidateAll()
|
|
|
|
if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) {
|
|
t.Fatal("expected SendMessages to be denied via channel override")
|
|
}
|
|
// ReadMessages should still be allowed.
|
|
if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ReadMessages) {
|
|
t.Fatal("expected ReadMessages to remain allowed")
|
|
}
|
|
}
|
|
|
|
func TestHasChannelPerm_OverrideAllow(t *testing.T) {
|
|
svc, database := newTestPermService(t)
|
|
seedChannel(t, database, &db.Channel{ID: 10, Name: "special", Type: "text"})
|
|
// Allow ManageMessages (not in base role) via channel override.
|
|
seedChannelOverride(t, database, permissions.MemberRoleID, 10, permissions.ManageMessages, 0)
|
|
svc.InvalidateAll()
|
|
|
|
if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ManageMessages) {
|
|
t.Fatal("expected ManageMessages to be allowed via channel override")
|
|
}
|
|
}
|
|
|
|
func TestHasChannelPerm_AdminBypass(t *testing.T) {
|
|
database := newTestDB(t)
|
|
seedRole(t, database, &db.Role{
|
|
ID: permissions.AdminRoleID,
|
|
Name: "admin",
|
|
Permissions: permissions.Administrator,
|
|
Position: 90,
|
|
})
|
|
seedUserRole(t, database, 1, permissions.AdminRoleID)
|
|
checker := permissions.NewChecker(database)
|
|
svc := NewPermissionService(database, checker)
|
|
|
|
seedChannel(t, database, &db.Channel{ID: 10, Name: "locked", Type: "text"})
|
|
// Deny everything via override; admin should still bypass.
|
|
seedChannelOverride(t, database, permissions.AdminRoleID, 10, 0, permissions.SendMessages|permissions.ReadMessages)
|
|
|
|
if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) {
|
|
t.Fatal("admin should bypass all permission checks")
|
|
}
|
|
if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ManageMessages) {
|
|
t.Fatal("admin should bypass all permission checks")
|
|
}
|
|
}
|
|
|
|
// TestHasChannelPerm_AdminSkipsOverrideFetch locks the admin skip in
|
|
// getOrPopulate: fail-closed must not extend to admins, who bypass every
|
|
// channel check anyway. Without the skip, an override-fetch outage would
|
|
// deny admins everything instead of nothing.
|
|
func TestHasChannelPerm_AdminSkipsOverrideFetch(t *testing.T) {
|
|
database := newTestDB(t)
|
|
seedRole(t, database, &db.Role{
|
|
ID: permissions.AdminRoleID,
|
|
Name: "admin",
|
|
Permissions: permissions.Administrator,
|
|
Position: 90,
|
|
})
|
|
seedUserRole(t, database, 1, permissions.AdminRoleID)
|
|
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
|
|
|
|
svc := NewPermissionService(errOverrideStore{DB: database}, permissions.NewChecker(database))
|
|
|
|
if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ManageMessages) {
|
|
t.Fatal("admin must not be denied by an override-fetch outage; the fetch is skipped for admins")
|
|
}
|
|
}
|
|
|
|
func TestInvalidateUser_ClearsCacheForUser(t *testing.T) {
|
|
svc, database := newTestPermService(t)
|
|
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
|
|
|
|
// Populate cache.
|
|
svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages)
|
|
|
|
// Now add a deny override.
|
|
seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages)
|
|
|
|
// Without invalidation, cache still says allowed.
|
|
if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) {
|
|
t.Fatal("expected cached value to still allow SendMessages")
|
|
}
|
|
|
|
// After invalidation, should pick up the override.
|
|
svc.InvalidateUser(1)
|
|
if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) {
|
|
t.Fatal("expected SendMessages to be denied after cache invalidation")
|
|
}
|
|
}
|
|
|
|
func TestInvalidateAll_ClearsEntireCache(t *testing.T) {
|
|
svc, database := newTestPermService(t)
|
|
// Add a second user.
|
|
seedUserRole(t, database, 2, permissions.MemberRoleID)
|
|
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
|
|
|
|
// Populate cache for both users.
|
|
svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages)
|
|
svc.HasChannelPerm(context.Background(), 2, 10, permissions.SendMessages)
|
|
|
|
// Add deny override.
|
|
seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages)
|
|
|
|
// Both still cached as allowed.
|
|
if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) {
|
|
t.Fatal("expected cached allow for user 1")
|
|
}
|
|
if !svc.HasChannelPerm(context.Background(), 2, 10, permissions.SendMessages) {
|
|
t.Fatal("expected cached allow for user 2")
|
|
}
|
|
|
|
svc.InvalidateAll()
|
|
|
|
// Both should now see the deny.
|
|
if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) {
|
|
t.Fatal("expected deny for user 1 after InvalidateAll")
|
|
}
|
|
if svc.HasChannelPerm(context.Background(), 2, 10, permissions.SendMessages) {
|
|
t.Fatal("expected deny for user 2 after InvalidateAll")
|
|
}
|
|
}
|
|
|
|
func TestPermCacheTTLExpiry(t *testing.T) {
|
|
// This test verifies the cache TTL mechanism. We cannot easily wait 30s
|
|
// in a unit test, so we verify the structural behavior: after manually
|
|
// backdating the populatedAt field the cache should be stale and the
|
|
// next check should re-populate from the store.
|
|
svc, database := newTestPermService(t)
|
|
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
|
|
|
|
// Populate cache.
|
|
svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages)
|
|
|
|
// Add deny override.
|
|
seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages)
|
|
|
|
// Manually expire the cache entry by backdating populatedAt.
|
|
svc.mu.Lock()
|
|
if cp, ok := svc.cache[int64(1)]; ok {
|
|
cp.populatedAt = time.Now().Add(-permCacheTTL - time.Second)
|
|
}
|
|
svc.mu.Unlock()
|
|
|
|
// The next call should re-populate and pick up the deny.
|
|
if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) {
|
|
t.Fatal("expected cache TTL expiry to cause re-population with deny override")
|
|
}
|
|
}
|
|
|
|
func TestHasChannelPerm_UnknownUserReturnsFalse(t *testing.T) {
|
|
svc, database := newTestPermService(t)
|
|
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
|
|
|
|
// User 999 has no role assigned.
|
|
if svc.HasChannelPerm(context.Background(), 999, 10, permissions.SendMessages) {
|
|
t.Fatal("expected false for unknown user")
|
|
}
|
|
}
|
|
|
|
// raceHookStore wraps a real *db.DB and fires a hook right after the role read
|
|
// inside getOrPopulate, letting a test deterministically inject a concurrent
|
|
// invalidation into the populate's read→store window.
|
|
type raceHookStore struct {
|
|
*db.DB
|
|
onGetRole func()
|
|
}
|
|
|
|
func (s *raceHookStore) GetRoleForUser(ctx context.Context, userID int64) (*db.Role, error) {
|
|
r, err := s.DB.GetRoleForUser(ctx, userID)
|
|
if s.onGetRole != nil {
|
|
s.onGetRole()
|
|
}
|
|
return r, err
|
|
}
|
|
|
|
// TestGetOrPopulate_InvalidationDuringPopulateNotLost locks F6: an invalidation
|
|
// that races a populate (landing after the DB read but before the cache store)
|
|
// must not be silently overwritten by the stale snapshot. Otherwise a just-revoked
|
|
// permission keeps being served for up to permCacheTTL (30s). All three
|
|
// invalidation entry points bump the generation, so each is locked separately.
|
|
func TestGetOrPopulate_InvalidationDuringPopulateNotLost(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
invalidate func(*PermissionService)
|
|
}{
|
|
{"InvalidateUser", func(s *PermissionService) { s.InvalidateUser(1) }},
|
|
{"InvalidateChannel", func(s *PermissionService) { s.InvalidateChannel(10) }},
|
|
{"InvalidateAll", func(s *PermissionService) { s.InvalidateAll() }},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
database := newTestDB(t)
|
|
seedRole(t, database, &db.Role{
|
|
ID: permissions.MemberRoleID,
|
|
Name: "member",
|
|
Permissions: permissions.SendMessages | permissions.ReadMessages,
|
|
Position: 1,
|
|
})
|
|
seedUserRole(t, database, 1, permissions.MemberRoleID)
|
|
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
|
|
|
|
store := &raceHookStore{DB: database}
|
|
svc := NewPermissionService(store, permissions.NewChecker(database))
|
|
|
|
fired := false
|
|
store.onGetRole = func() {
|
|
if fired {
|
|
return
|
|
}
|
|
fired = true
|
|
// Admin demotes the role (removes SendMessages) and invalidates,
|
|
// racing this populate between its role read and its cache store.
|
|
if _, err := database.ExecContext(context.Background(), `UPDATE roles SET permissions = ? WHERE id = ?`,
|
|
permissions.ReadMessages, permissions.MemberRoleID); err != nil {
|
|
t.Errorf("demote role: %v", err)
|
|
}
|
|
tc.invalidate(svc)
|
|
}
|
|
|
|
// This populate reads the pre-demotion perms; the racing invalidation
|
|
// must stop that stale snapshot from being cached.
|
|
svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages)
|
|
|
|
// A fresh check must re-read the DB and see the revoked permission.
|
|
if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) {
|
|
t.Fatal("revoked SendMessages served from a stale snapshot; a populate that races an invalidation must not be cached")
|
|
}
|
|
})
|
|
}
|
|
}
|