Files

128 lines
4.8 KiB
Go
Raw Permalink Normal View History

package ws_test
// perm_cache_test.go — Phase 2 (ws permission cache) coverage: the ws-side
// permission gates now answer from service.PermissionService. Two invariants
// are locked here:
// 1. A role change performed through the invalidating path (the DB write plus
// InvalidateUser, exactly what admin/handlers_users.go does) is visible to
// the very next ws permission check — no cache-TTL wait.
// 2. The cache is actually consulted: a second check for the same user does
// not re-read the role from the store.
import (
"context"
"encoding/json"
"sync/atomic"
"testing"
"time"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
"github.com/J3vb/OwnCord/Server/service"
"github.com/J3vb/OwnCord/Server/ws"
)
// voiceJoinRaw builds a raw voice_join envelope for channelID.
func voiceJoinRaw(channelID int64) []byte {
raw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{"channel_id": channelID},
})
return raw
}
// TestPermCache_RoleChangeInvalidationIsImmediate drives the voice_join
// CONNECT_VOICE gate (a cached ws-side check) before and after a role change
// that mirrors the admin handler: DB write, then InvalidateUser. The demotion
// must take effect on the immediately following check, with no TTL wait.
// LiveKit is deliberately not configured, so a PASSED gate surfaces as
// VOICE_ERROR ("voice is not configured") instead of FORBIDDEN — which lets
// the test observe the gate verdict without a real SFU.
func TestPermCache_RoleChangeInvalidationIsImmediate(t *testing.T) {
database := openHandlerDB(t)
limiter := auth.NewRateLimiter()
svc := service.New(database, limiter)
hub := ws.NewHub(database, limiter, svc)
go hub.Run()
t.Cleanup(func() { hub.Stop() })
user := seedMemberUser(t, database, "cache-demote") // Member: has CONNECT_VOICE
chID := seedTestChannel(t, database, "cache-demote-vc")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.RegisterNowForTest(c)
// First join: the gate passes (and populates the user's cache entry); the
// join then fails on the unconfigured LiveKit, not on permissions.
hub.HandleMessageForTest(c, voiceJoinRaw(chID))
if code := receiveErrorCode(send, 300*time.Millisecond); code == "FORBIDDEN" {
t.Fatalf("member with CONNECT_VOICE was denied the voice_join gate (code %q)", code)
}
// Demote through the invalidating path, mirroring admin/handlers_users.go:
// role write first, then InvalidateUser.
if _, err := database.ExecContext(context.Background(),
`INSERT INTO roles (id, name, color, permissions, position, is_default)
VALUES (150, 'cache-novoice', NULL, ?, 5, 0)`,
permissions.ReadMessages,
); err != nil {
t.Fatalf("seed novoice role: %v", err)
}
if _, err := database.ExecContext(context.Background(),
`UPDATE users SET role_id = 150 WHERE id = ?`, user.ID); err != nil {
t.Fatalf("reassign user role: %v", err)
}
svc.Permissions.InvalidateUser(user.ID)
// The very next check must see the demoted role — cached entry gone.
hub.HandleMessageForTest(c, voiceJoinRaw(chID))
if code := receiveErrorCode(send, 300*time.Millisecond); code != "FORBIDDEN" {
t.Fatalf("demoted user passed the CONNECT_VOICE gate right after InvalidateUser, got code %q, want FORBIDDEN", code)
}
}
// countingStore wraps a service.Store and counts GetRoleForUser calls — the
// query every uncached ws-side permission check must issue.
type countingStore struct {
service.Store
roleLookups atomic.Int64
}
func (s *countingStore) GetRoleForUser(ctx context.Context, userID int64) (*db.Role, error) {
s.roleLookups.Add(1)
return s.Store.GetRoleForUser(ctx, userID)
}
// TestPermCache_SecondCheckServedFromCache proves the ws gates actually use the
// cache: two consecutive voice_join permission checks for the same user issue
// exactly one role lookup against the store — the second is a cache hit.
func TestPermCache_SecondCheckServedFromCache(t *testing.T) {
database := openHandlerDB(t)
limiter := auth.NewRateLimiter()
store := &countingStore{Store: database}
svc := service.New(store, limiter)
hub := ws.NewHub(database, limiter, svc)
go hub.Run()
t.Cleanup(func() { hub.Stop() })
user := seedMemberUser(t, database, "cache-hit")
chID := seedTestChannel(t, database, "cache-hit-vc")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.RegisterNowForTest(c)
for i := range 2 {
hub.HandleMessageForTest(c, voiceJoinRaw(chID))
if code := receiveErrorCode(send, 300*time.Millisecond); code == "FORBIDDEN" {
t.Fatalf("join %d: member with CONNECT_VOICE was denied the gate", i+1)
}
}
if got := store.roleLookups.Load(); got != 1 {
t.Fatalf("role lookups through the permission service = %d, want 1 (second check must be a cache hit)", got)
}
}