@quenty/permissionprovider 14.44.0 → 14.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,10 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [14.44.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/permissionprovider@14.44.0...@quenty/permissionprovider@14.44.1) (2026-07-23)
7
+
8
+ **Note:** Version bump only for package @quenty/permissionprovider
9
+
6
10
  # [14.44.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/permissionprovider@14.43.0...@quenty/permissionprovider@14.44.0) (2026-07-23)
7
11
 
8
12
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/permissionprovider",
3
- "version": "14.44.0",
3
+ "version": "14.44.1",
4
4
  "description": "Permission provider for Roblox, including authenticating against group membership",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -31,14 +31,14 @@
31
31
  "@quenty/baseobject": "10.15.0",
32
32
  "@quenty/brio": "14.33.0",
33
33
  "@quenty/enums": "1.3.0",
34
- "@quenty/grouputils": "10.23.0",
34
+ "@quenty/grouputils": "10.23.1",
35
35
  "@quenty/loader": "10.11.0",
36
36
  "@quenty/maid": "3.11.0",
37
37
  "@quenty/nevermore-test-runner": "1.5.0",
38
- "@quenty/playermock": "1.1.0",
39
- "@quenty/playerutils": "8.36.0",
38
+ "@quenty/playermock": "1.1.1",
39
+ "@quenty/playerutils": "8.36.1",
40
40
  "@quenty/promise": "10.21.0",
41
- "@quenty/remoting": "12.36.0",
41
+ "@quenty/remoting": "12.36.1",
42
42
  "@quenty/rx": "13.31.0",
43
43
  "@quenty/servicebag": "11.20.0",
44
44
  "@quenty/table": "3.9.2",
@@ -47,5 +47,5 @@
47
47
  "publishConfig": {
48
48
  "access": "public"
49
49
  },
50
- "gitHead": "1de37218a2bedb8e3f8614a2e09bba9eddc812da"
50
+ "gitHead": "5ea0583dedebba6492f9ac6d719d32b45c5543fa"
51
51
  }
@@ -15,6 +15,7 @@ local require = require(script.Parent.loader).load(script)
15
15
  local Maid = require("Maid")
16
16
  local PermissionProviderClient = require("PermissionProviderClient")
17
17
  local PermissionProviderConstants = require("PermissionProviderConstants")
18
+ local PlayerMock = require("PlayerMock")
18
19
  local Promise = require("Promise")
19
20
  local ServiceBag = require("ServiceBag")
20
21
 
@@ -53,7 +54,10 @@ function PermissionServiceClient.PromiseIsAdmin(
53
54
  self: PermissionServiceClient,
54
55
  player: Player?
55
56
  ): Promise.Promise<boolean>
56
- assert((typeof(player) == "Instance" and player:IsA("Player")) or player == nil, "Bad player")
57
+ assert(
58
+ (typeof(player) == "Instance" and (player:IsA("Player") or PlayerMock.isMock(player))) or player == nil,
59
+ "Bad player"
60
+ )
57
61
 
58
62
  return self:PromisePermissionProvider():Then(function(permissionProvider)
59
63
  return permissionProvider:PromiseIsAdmin(player)
@@ -0,0 +1,176 @@
1
+ --!strict
2
+ --[[
3
+ Coverage for PermissionServiceClient booted headless in a client ServiceBag against a
4
+ pre-designated mock local player (designation precedes Init -- production parity, where
5
+ Players.LocalPlayer exists before any service runs). Real remotes can never reach a server
6
+ realm headless, so server answers are driven by stubbing the remote promise cached by the
7
+ resolved PermissionProviderClient. The deny case assumes RunService:IsStudio() == false (true
8
+ in the cloud test runner); in Studio the provider short-circuits every answer to true.
9
+
10
+ Bad-player failures surface as synchronous throws, not rejections: the provider promise is
11
+ already fulfilled, so the Then handler (and the provider's asserts) run inline at call time.
12
+
13
+ @class PermissionServiceClient.spec.lua
14
+ ]]
15
+
16
+ local require = require(script.Parent.loader).load(script)
17
+
18
+ local Workspace = game:GetService("Workspace")
19
+
20
+ local Jest = require("Jest")
21
+ local Maid = require("Maid")
22
+ local PermissionServiceClient = require("PermissionServiceClient")
23
+ local PlayerMock = require("PlayerMock")
24
+ local Promise = require("Promise")
25
+ local PromiseTestUtils = require("PromiseTestUtils")
26
+ local ServiceBag = require("ServiceBag")
27
+
28
+ local describe = Jest.Globals.describe
29
+ local expect = Jest.Globals.expect
30
+ local it = Jest.Globals.it
31
+
32
+ local LOCAL_USER_ID = 88772001
33
+
34
+ local function setup()
35
+ local maid = Maid.new()
36
+
37
+ local localPlayer = maid:Add(PlayerMock.new({ UserId = LOCAL_USER_ID }))
38
+ localPlayer.Parent = Workspace
39
+ PlayerMock.setMockedLocalPlayer(localPlayer)
40
+
41
+ local serviceBag = maid:Add(ServiceBag.new())
42
+ local service: PermissionServiceClient.PermissionServiceClient =
43
+ serviceBag:GetService(PermissionServiceClient) :: any
44
+ serviceBag:Init()
45
+ serviceBag:Start()
46
+
47
+ return {
48
+ localPlayer = localPlayer,
49
+ serviceBag = serviceBag,
50
+ service = service,
51
+ stubServerAnswer = function(invokeServer: () -> any)
52
+ local ok, provider = service:PromisePermissionProvider():Yield()
53
+ assert(ok, "No provider resolved")
54
+
55
+ local anyProvider: any = provider
56
+ anyProvider._remoteFunctionPromise = Promise.resolved({
57
+ InvokeServer = function(_self)
58
+ return invokeServer()
59
+ end,
60
+ })
61
+ end,
62
+ fakePlayer = function(userId: number): Player
63
+ return maid:Add(PlayerMock.new({ UserId = userId }))
64
+ end,
65
+ awaitBool = function(promise: any): boolean
66
+ expect(PromiseTestUtils.awaitSettled(promise, 5)).toEqual(true)
67
+ local ok, value = promise:Yield()
68
+ expect(ok).toEqual(true)
69
+ return value
70
+ end,
71
+ destroy = function(_self)
72
+ PlayerMock.setMockedLocalPlayer(nil)
73
+ maid:DoCleaning()
74
+ end,
75
+ }
76
+ end
77
+
78
+ describe("PermissionServiceClient initialization", function()
79
+ it("resolves the permission provider immediately after init", function()
80
+ local controller = setup()
81
+
82
+ local promise = controller.service:PromisePermissionProvider()
83
+ expect(promise:IsPending()).toEqual(false)
84
+
85
+ local ok, provider = promise:Yield()
86
+ expect(ok).toEqual(true)
87
+ expect((provider :: any).ClassName).toEqual("PermissionProviderClient")
88
+
89
+ controller:destroy()
90
+ end)
91
+
92
+ it("rejects double initialization", function()
93
+ local controller = setup()
94
+
95
+ local bare: any = setmetatable({}, { __index = PermissionServiceClient })
96
+ bare:Init(controller.serviceBag)
97
+
98
+ expect(function()
99
+ bare:Init(controller.serviceBag)
100
+ end).toThrow("Already initialized")
101
+
102
+ bare:Destroy()
103
+ controller:destroy()
104
+ end)
105
+ end)
106
+
107
+ describe("PermissionServiceClient.PromiseIsAdmin", function()
108
+ it("rejects a non-player value", function()
109
+ local controller = setup()
110
+
111
+ expect(function()
112
+ controller.service:PromiseIsAdmin(5 :: any)
113
+ end).toThrow("Bad player")
114
+
115
+ controller:destroy()
116
+ end)
117
+
118
+ it("resolves the server's answer for the designated local player", function()
119
+ local controller = setup()
120
+ controller.stubServerAnswer(function()
121
+ return true
122
+ end)
123
+
124
+ expect(controller.awaitBool(controller.service:PromiseIsAdmin(controller.localPlayer))).toEqual(true)
125
+
126
+ controller:destroy()
127
+ end)
128
+
129
+ it("resolves the server's answer for a nil player", function()
130
+ local controller = setup()
131
+ controller.stubServerAnswer(function()
132
+ return true
133
+ end)
134
+
135
+ expect(controller.awaitBool(controller.service:PromiseIsAdmin(nil))).toEqual(true)
136
+
137
+ controller:destroy()
138
+ end)
139
+
140
+ it("denies the designated local player when the server answers false", function()
141
+ local controller = setup()
142
+ controller.stubServerAnswer(function()
143
+ return false
144
+ end)
145
+
146
+ expect(controller.awaitBool(controller.service:PromiseIsAdmin(controller.localPlayer))).toEqual(false)
147
+
148
+ controller:destroy()
149
+ end)
150
+
151
+ it("throws for a mock that is not the designated local player", function()
152
+ local controller = setup()
153
+ local otherPlayer = controller.fakePlayer(LOCAL_USER_ID + 1)
154
+
155
+ expect(function()
156
+ controller.service:PromiseIsAdmin(otherPlayer)
157
+ end).toThrow("We only support local player")
158
+
159
+ controller:destroy()
160
+ end)
161
+
162
+ it("caches the server answer across calls", function()
163
+ local controller = setup()
164
+ local invokeCount = 0
165
+ controller.stubServerAnswer(function()
166
+ invokeCount += 1
167
+ return true
168
+ end)
169
+
170
+ expect(controller.awaitBool(controller.service:PromiseIsAdmin(controller.localPlayer))).toEqual(true)
171
+ expect(controller.awaitBool(controller.service:PromiseIsAdmin(controller.localPlayer))).toEqual(true)
172
+ expect(invokeCount).toEqual(1)
173
+
174
+ controller:destroy()
175
+ end)
176
+ end)
@@ -12,6 +12,7 @@ local Players = game:GetService("Players")
12
12
  local RunService = game:GetService("RunService")
13
13
 
14
14
  local PermissionProviderConstants = require("PermissionProviderConstants")
15
+ local PlayerMock = require("PlayerMock")
15
16
  local Promise = require("Promise")
16
17
  local PromiseGetRemoteFunction = require("PromiseGetRemoteFunction")
17
18
 
@@ -46,10 +47,15 @@ function PermissionProviderClient.PromiseIsAdmin(
46
47
  self: PermissionProviderClient,
47
48
  player: Player?
48
49
  ): Promise.Promise<boolean>
49
- assert(typeof(player) == "Instance" and player:IsA("Player") or player == nil, "Bad player")
50
+ assert(
51
+ typeof(player) == "Instance" and (player:IsA("Player") or PlayerMock.isMock(player)) or player == nil,
52
+ "Bad player"
53
+ )
50
54
 
51
55
  if player ~= nil then
52
- assert(player == Players.LocalPlayer, "We only support local player for now")
56
+ -- Headless (test) sessions have no Players.LocalPlayer; the designated PlayerMock stands in.
57
+ local localPlayer = Players.LocalPlayer or PlayerMock.getMockedLocalPlayer()
58
+ assert(player == localPlayer, "We only support local player for now")
53
59
  end
54
60
 
55
61
  if player == nil then
@@ -0,0 +1,186 @@
1
+ --!strict
2
+ --[[
3
+ Coverage for PermissionProviderClient against a stubbed server remote. Headless sessions
4
+ (RunService:IsRunning() == false) hand out detached mock RemoteFunctions that can never reach a
5
+ server realm, so tests inject the remote promise the provider caches and answer InvokeServer
6
+ directly. The deny case assumes RunService:IsStudio() == false (true in the cloud test runner);
7
+ in Studio the provider short-circuits every answer to true.
8
+
9
+ @class PermissionProviderClient.spec.lua
10
+ ]]
11
+
12
+ local require = require(script.Parent.loader).load(script)
13
+
14
+ local Workspace = game:GetService("Workspace")
15
+
16
+ local Jest = require("Jest")
17
+ local Maid = require("Maid")
18
+ local PermissionProviderClient = require("PermissionProviderClient")
19
+ local PermissionProviderConstants = require("PermissionProviderConstants")
20
+ local PlayerMock = require("PlayerMock")
21
+ local Promise = require("Promise")
22
+ local PromiseTestUtils = require("PromiseTestUtils")
23
+
24
+ local describe = Jest.Globals.describe
25
+ local expect = Jest.Globals.expect
26
+ local it = Jest.Globals.it
27
+
28
+ local LOCAL_USER_ID = 88771001
29
+
30
+ local function setup()
31
+ local maid = Maid.new()
32
+
33
+ local localPlayer = maid:Add(PlayerMock.new({ UserId = LOCAL_USER_ID }))
34
+ localPlayer.Parent = Workspace
35
+ PlayerMock.setMockedLocalPlayer(localPlayer)
36
+
37
+ return {
38
+ localPlayer = localPlayer,
39
+ makeProvider = function(invokeServer: (() -> any)?): any
40
+ local provider: any = PermissionProviderClient.new("PermissionProviderClientSpecRemote")
41
+ if invokeServer then
42
+ provider._remoteFunctionPromise = Promise.resolved({
43
+ InvokeServer = function(_self)
44
+ return invokeServer()
45
+ end,
46
+ })
47
+ end
48
+ return provider
49
+ end,
50
+ fakePlayer = function(userId: number): Player
51
+ return maid:Add(PlayerMock.new({ UserId = userId }))
52
+ end,
53
+ awaitBool = function(promise: any): boolean
54
+ expect(PromiseTestUtils.awaitSettled(promise, 5)).toEqual(true)
55
+ local ok, value = promise:Yield()
56
+ expect(ok).toEqual(true)
57
+ return value
58
+ end,
59
+ awaitError = function(promise: any): string
60
+ expect(PromiseTestUtils.awaitSettled(promise, 5)).toEqual(true)
61
+ local ok, err = promise:Yield()
62
+ expect(ok).toEqual(false)
63
+ return tostring(err)
64
+ end,
65
+ destroy = function(_self)
66
+ PlayerMock.setMockedLocalPlayer(nil)
67
+ maid:DoCleaning()
68
+ end,
69
+ }
70
+ end
71
+
72
+ describe("PermissionProviderClient construction", function()
73
+ it("defaults the remote function name when none is given", function()
74
+ local provider: any = PermissionProviderClient.new(nil :: any)
75
+
76
+ expect(provider._remoteFunctionName).toEqual(PermissionProviderConstants.DEFAULT_REMOTE_FUNCTION_NAME)
77
+ end)
78
+
79
+ it("uses the provided remote function name", function()
80
+ local provider: any = PermissionProviderClient.new("PermissionProviderClientSpecRemote")
81
+
82
+ expect(provider._remoteFunctionName).toEqual("PermissionProviderClientSpecRemote")
83
+ end)
84
+ end)
85
+
86
+ describe("PermissionProviderClient.PromiseIsAdmin argument validation", function()
87
+ it("rejects a non-player value", function()
88
+ local controller = setup()
89
+ local provider = controller.makeProvider()
90
+
91
+ expect(function()
92
+ provider:PromiseIsAdmin(5 :: any)
93
+ end).toThrow("Bad player")
94
+
95
+ controller:destroy()
96
+ end)
97
+
98
+ it("rejects a player other than the designated local player", function()
99
+ local controller = setup()
100
+ local provider = controller.makeProvider()
101
+ local otherPlayer = controller.fakePlayer(LOCAL_USER_ID + 1)
102
+
103
+ expect(function()
104
+ provider:PromiseIsAdmin(otherPlayer)
105
+ end).toThrow("We only support local player")
106
+
107
+ controller:destroy()
108
+ end)
109
+ end)
110
+
111
+ describe("PermissionProviderClient.PromiseIsAdmin", function()
112
+ it("resolves the server's answer for the designated local player", function()
113
+ local controller = setup()
114
+ local provider = controller.makeProvider(function()
115
+ return true
116
+ end)
117
+
118
+ expect(controller.awaitBool(provider:PromiseIsAdmin(controller.localPlayer))).toEqual(true)
119
+
120
+ controller:destroy()
121
+ end)
122
+
123
+ it("resolves the server's answer for a nil player", function()
124
+ local controller = setup()
125
+ local provider = controller.makeProvider(function()
126
+ return true
127
+ end)
128
+
129
+ expect(controller.awaitBool(provider:PromiseIsAdmin(nil))).toEqual(true)
130
+
131
+ controller:destroy()
132
+ end)
133
+
134
+ it("denies when the server answers false", function()
135
+ local controller = setup()
136
+ local provider = controller.makeProvider(function()
137
+ return false
138
+ end)
139
+
140
+ expect(controller.awaitBool(provider:PromiseIsAdmin(controller.localPlayer))).toEqual(false)
141
+
142
+ controller:destroy()
143
+ end)
144
+
145
+ it("rejects a non-boolean server answer", function()
146
+ local controller = setup()
147
+ local provider = controller.makeProvider(function()
148
+ return "yes"
149
+ end)
150
+
151
+ local message = controller.awaitError(provider:PromiseIsAdmin(controller.localPlayer))
152
+ expect(message).toContain("Got non-boolean from server")
153
+
154
+ controller:destroy()
155
+ end)
156
+
157
+ it("rejects when the server invoke throws", function()
158
+ local controller = setup()
159
+ local provider = controller.makeProvider(function()
160
+ error("Server invoke failed")
161
+ end)
162
+
163
+ local message = controller.awaitError(provider:PromiseIsAdmin(controller.localPlayer))
164
+ expect(message).toContain("Server invoke failed")
165
+
166
+ controller:destroy()
167
+ end)
168
+
169
+ it("caches the admin answer across calls", function()
170
+ local controller = setup()
171
+ local invokeCount = 0
172
+ local provider = controller.makeProvider(function()
173
+ invokeCount += 1
174
+ return true
175
+ end)
176
+
177
+ local first = provider:PromiseIsAdmin(controller.localPlayer)
178
+ local second = provider:PromiseIsAdmin(controller.localPlayer)
179
+
180
+ expect(second).toBe(first)
181
+ expect(controller.awaitBool(first)).toEqual(true)
182
+ expect(invokeCount).toEqual(1)
183
+
184
+ controller:destroy()
185
+ end)
186
+ end)