@pyreon/permissions 0.11.3 → 0.11.5
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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +11 -7
- package/lib/index.js.map +1 -1
- package/package.json +5 -5
- package/src/permissions.ts +13 -9
- package/src/tests/api.test.ts +362 -0
- package/src/tests/context.test.ts +50 -0
- package/src/tests/predicates.test.ts +149 -0
- package/src/tests/wildcards.test.ts +141 -0
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"5071c99d-1","name":"context.ts"},{"uid":"5071c99d-3","name":"permissions.ts"},{"uid":"5071c99d-5","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"5071c99d-1":{"renderedLength":933,"gzipLength":457,"brotliLength":0,"metaUid":"5071c99d-0"},"5071c99d-3":{"renderedLength":2817,"gzipLength":1123,"brotliLength":0,"metaUid":"5071c99d-2"},"5071c99d-5":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"5071c99d-4"}},"nodeMetas":{"5071c99d-0":{"id":"/src/context.ts","moduleParts":{"index.js":"5071c99d-1"},"imported":[{"uid":"5071c99d-6"}],"importedBy":[{"uid":"5071c99d-4"}]},"5071c99d-2":{"id":"/src/permissions.ts","moduleParts":{"index.js":"5071c99d-3"},"imported":[{"uid":"5071c99d-7"}],"importedBy":[{"uid":"5071c99d-4"}]},"5071c99d-4":{"id":"/src/index.ts","moduleParts":{"index.js":"5071c99d-5"},"imported":[{"uid":"5071c99d-0"},{"uid":"5071c99d-2"}],"importedBy":[],"isEntry":true},"5071c99d-6":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"5071c99d-0"}]},"5071c99d-7":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"5071c99d-2"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createContext, provide, useContext } from "@pyreon/core";
|
|
2
|
-
import { computed, signal } from "@pyreon/reactivity";
|
|
2
|
+
import { batch, computed, signal } from "@pyreon/reactivity";
|
|
3
3
|
|
|
4
4
|
//#region src/context.ts
|
|
5
5
|
const PermissionsContext = createContext(null);
|
|
@@ -116,14 +116,18 @@ function createPermissions(initial) {
|
|
|
116
116
|
return keys.some((key) => can(key));
|
|
117
117
|
};
|
|
118
118
|
can.set = (permissions) => {
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
batch(() => {
|
|
120
|
+
store.set(toMap(permissions));
|
|
121
|
+
version.update((v) => v + 1);
|
|
122
|
+
});
|
|
121
123
|
};
|
|
122
124
|
can.patch = (permissions) => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
batch(() => {
|
|
126
|
+
const current = store.peek();
|
|
127
|
+
for (const [key, value] of Object.entries(permissions)) current.set(key, value);
|
|
128
|
+
store.set(current);
|
|
129
|
+
version.update((v) => v + 1);
|
|
130
|
+
});
|
|
127
131
|
};
|
|
128
132
|
can.granted = computed(() => {
|
|
129
133
|
version();
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/context.ts","../src/permissions.ts"],"sourcesContent":["import type { VNodeChild } from \"@pyreon/core\"\nimport { createContext, provide, useContext } from \"@pyreon/core\"\nimport type { Permissions } from \"./types\"\n\nconst PermissionsContext = createContext<Permissions | null>(null)\n\n/**\n * Provide a permissions instance to descendant components.\n * Use this for SSR isolation or testing — each request/test gets its own instance.\n *\n * @example\n * ```tsx\n * const can = createPermissions({ ... })\n *\n * <PermissionsProvider instance={can}>\n * <App />\n * </PermissionsProvider>\n * ```\n */\nexport function PermissionsProvider(props: {\n instance: Permissions\n children?: VNodeChild\n}): VNodeChild {\n provide(PermissionsContext, props.instance)\n\n return props.children ?? null\n}\n\n/**\n * Access the nearest permissions instance from context.\n * Must be used within a `<PermissionsProvider>`.\n *\n * @example\n * ```tsx\n * const can = usePermissions()\n * {() => can('posts.read') && <PostList />}\n * ```\n */\nexport function usePermissions(): Permissions {\n const instance = useContext(PermissionsContext)\n if (!instance) {\n throw new Error(\n \"[@pyreon/permissions] usePermissions() must be used within <PermissionsProvider>.\",\n )\n }\n return instance\n}\n","import { computed, signal } from \"@pyreon/reactivity\"\nimport type { PermissionMap, Permissions, PermissionValue } from \"./types\"\n\n/**\n * Resolve a permission key against the map.\n * Resolution order: exact match → wildcard (e.g., 'posts.*') → global wildcard ('*') → false.\n */\n/**\n * Safely evaluate a permission value. Predicates that throw are treated as denied.\n */\nfunction evaluate(value: PermissionValue, context?: unknown): boolean {\n if (typeof value === \"function\") {\n try {\n return value(context)\n } catch {\n return false\n }\n }\n return value\n}\n\nfunction resolve(map: Map<string, PermissionValue>, key: string, context?: unknown): boolean {\n // 1. Exact match\n const exact = map.get(key)\n if (exact !== undefined) {\n return evaluate(exact, context)\n }\n\n // 2. Wildcard match — 'posts.read' matches 'posts.*'\n const dotIndex = key.lastIndexOf(\".\")\n if (dotIndex !== -1) {\n const prefix = key.slice(0, dotIndex)\n const wildcard = map.get(`${prefix}.*`)\n if (wildcard !== undefined) {\n return evaluate(wildcard, context)\n }\n }\n\n // 3. Global wildcard\n const global = map.get(\"*\")\n if (global !== undefined) {\n return evaluate(global, context)\n }\n\n // 4. No match → denied\n return false\n}\n\n/**\n * Create a reactive permissions instance.\n *\n * The returned `can` function checks permissions reactively —\n * reads update automatically when permissions change via `set()` or `patch()`.\n *\n * @param initial - Optional initial permission map\n * @returns A callable `Permissions` instance\n *\n * @example\n * ```tsx\n * const can = createPermissions({\n * 'posts.read': true,\n * 'posts.update': (post: Post) => post.authorId === userId(),\n * 'users.manage': false,\n * })\n *\n * // Check (reactive in effects/computeds/JSX)\n * can('posts.read') // true\n * can('posts.update', myPost) // evaluates predicate\n *\n * // JSX\n * {() => can('posts.delete') && <DeleteButton />}\n *\n * // Update\n * can.set({ 'posts.read': true, 'admin': true })\n * can.patch({ 'users.manage': true })\n * ```\n */\nexport function createPermissions(initial?: PermissionMap): Permissions {\n // Internal reactive state — a signal holding the permission map\n const store = signal(toMap(initial))\n // Version counter — incremented on every set/patch to trigger reactive updates\n const version = signal(0)\n\n function toMap(obj?: PermissionMap): Map<string, PermissionValue> {\n if (!obj) return new Map()\n return new Map(Object.entries(obj))\n }\n\n // The main check function — reads `version` to subscribe in reactive contexts\n function can(key: string, context?: unknown): boolean {\n // Reading version subscribes this call to reactive updates\n version()\n return resolve(store.peek(), key, context)\n }\n\n can.not = (key: string, context?: unknown): boolean => {\n return !can(key, context)\n }\n\n can.all = (...keys: string[]): boolean => {\n return keys.every((key) => can(key))\n }\n\n can.any = (...keys: string[]): boolean => {\n return keys.some((key) => can(key))\n }\n\n can.set = (permissions: PermissionMap): void => {\n store.set(toMap(permissions))\n
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/context.ts","../src/permissions.ts"],"sourcesContent":["import type { VNodeChild } from \"@pyreon/core\"\nimport { createContext, provide, useContext } from \"@pyreon/core\"\nimport type { Permissions } from \"./types\"\n\nconst PermissionsContext = createContext<Permissions | null>(null)\n\n/**\n * Provide a permissions instance to descendant components.\n * Use this for SSR isolation or testing — each request/test gets its own instance.\n *\n * @example\n * ```tsx\n * const can = createPermissions({ ... })\n *\n * <PermissionsProvider instance={can}>\n * <App />\n * </PermissionsProvider>\n * ```\n */\nexport function PermissionsProvider(props: {\n instance: Permissions\n children?: VNodeChild\n}): VNodeChild {\n provide(PermissionsContext, props.instance)\n\n return props.children ?? null\n}\n\n/**\n * Access the nearest permissions instance from context.\n * Must be used within a `<PermissionsProvider>`.\n *\n * @example\n * ```tsx\n * const can = usePermissions()\n * {() => can('posts.read') && <PostList />}\n * ```\n */\nexport function usePermissions(): Permissions {\n const instance = useContext(PermissionsContext)\n if (!instance) {\n throw new Error(\n \"[@pyreon/permissions] usePermissions() must be used within <PermissionsProvider>.\",\n )\n }\n return instance\n}\n","import { batch, computed, signal } from \"@pyreon/reactivity\"\nimport type { PermissionMap, Permissions, PermissionValue } from \"./types\"\n\n/**\n * Resolve a permission key against the map.\n * Resolution order: exact match → wildcard (e.g., 'posts.*') → global wildcard ('*') → false.\n */\n/**\n * Safely evaluate a permission value. Predicates that throw are treated as denied.\n */\nfunction evaluate(value: PermissionValue, context?: unknown): boolean {\n if (typeof value === \"function\") {\n try {\n return value(context)\n } catch {\n return false\n }\n }\n return value\n}\n\nfunction resolve(map: Map<string, PermissionValue>, key: string, context?: unknown): boolean {\n // 1. Exact match\n const exact = map.get(key)\n if (exact !== undefined) {\n return evaluate(exact, context)\n }\n\n // 2. Wildcard match — 'posts.read' matches 'posts.*'\n const dotIndex = key.lastIndexOf(\".\")\n if (dotIndex !== -1) {\n const prefix = key.slice(0, dotIndex)\n const wildcard = map.get(`${prefix}.*`)\n if (wildcard !== undefined) {\n return evaluate(wildcard, context)\n }\n }\n\n // 3. Global wildcard\n const global = map.get(\"*\")\n if (global !== undefined) {\n return evaluate(global, context)\n }\n\n // 4. No match → denied\n return false\n}\n\n/**\n * Create a reactive permissions instance.\n *\n * The returned `can` function checks permissions reactively —\n * reads update automatically when permissions change via `set()` or `patch()`.\n *\n * @param initial - Optional initial permission map\n * @returns A callable `Permissions` instance\n *\n * @example\n * ```tsx\n * const can = createPermissions({\n * 'posts.read': true,\n * 'posts.update': (post: Post) => post.authorId === userId(),\n * 'users.manage': false,\n * })\n *\n * // Check (reactive in effects/computeds/JSX)\n * can('posts.read') // true\n * can('posts.update', myPost) // evaluates predicate\n *\n * // JSX\n * {() => can('posts.delete') && <DeleteButton />}\n *\n * // Update\n * can.set({ 'posts.read': true, 'admin': true })\n * can.patch({ 'users.manage': true })\n * ```\n */\nexport function createPermissions(initial?: PermissionMap): Permissions {\n // Internal reactive state — a signal holding the permission map\n const store = signal(toMap(initial))\n // Version counter — incremented on every set/patch to trigger reactive updates\n const version = signal(0)\n\n function toMap(obj?: PermissionMap): Map<string, PermissionValue> {\n if (!obj) return new Map()\n return new Map(Object.entries(obj))\n }\n\n // The main check function — reads `version` to subscribe in reactive contexts\n function can(key: string, context?: unknown): boolean {\n // Reading version subscribes this call to reactive updates\n version()\n return resolve(store.peek(), key, context)\n }\n\n can.not = (key: string, context?: unknown): boolean => {\n return !can(key, context)\n }\n\n can.all = (...keys: string[]): boolean => {\n return keys.every((key) => can(key))\n }\n\n can.any = (...keys: string[]): boolean => {\n return keys.some((key) => can(key))\n }\n\n can.set = (permissions: PermissionMap): void => {\n batch(() => {\n store.set(toMap(permissions))\n version.update((v) => v + 1)\n })\n }\n\n can.patch = (permissions: PermissionMap): void => {\n batch(() => {\n const current = store.peek()\n for (const [key, value] of Object.entries(permissions)) {\n current.set(key, value)\n }\n store.set(current)\n version.update((v) => v + 1)\n })\n }\n\n can.granted = computed(() => {\n version()\n const keys: string[] = []\n for (const [key, value] of store.peek()) {\n // Static true or predicate (capability exists)\n if (value === true || typeof value === \"function\") {\n keys.push(key)\n }\n }\n return keys\n })\n\n can.entries = computed(() => {\n version()\n return [...store.peek().entries()]\n })\n\n return can as Permissions\n}\n"],"mappings":";;;;AAIA,MAAM,qBAAqB,cAAkC,KAAK;;;;;;;;;;;;;;AAelE,SAAgB,oBAAoB,OAGrB;AACb,SAAQ,oBAAoB,MAAM,SAAS;AAE3C,QAAO,MAAM,YAAY;;;;;;;;;;;;AAa3B,SAAgB,iBAA8B;CAC5C,MAAM,WAAW,WAAW,mBAAmB;AAC/C,KAAI,CAAC,SACH,OAAM,IAAI,MACR,oFACD;AAEH,QAAO;;;;;;;;;;;;ACnCT,SAAS,SAAS,OAAwB,SAA4B;AACpE,KAAI,OAAO,UAAU,WACnB,KAAI;AACF,SAAO,MAAM,QAAQ;SACf;AACN,SAAO;;AAGX,QAAO;;AAGT,SAAS,QAAQ,KAAmC,KAAa,SAA4B;CAE3F,MAAM,QAAQ,IAAI,IAAI,IAAI;AAC1B,KAAI,UAAU,OACZ,QAAO,SAAS,OAAO,QAAQ;CAIjC,MAAM,WAAW,IAAI,YAAY,IAAI;AACrC,KAAI,aAAa,IAAI;EACnB,MAAM,SAAS,IAAI,MAAM,GAAG,SAAS;EACrC,MAAM,WAAW,IAAI,IAAI,GAAG,OAAO,IAAI;AACvC,MAAI,aAAa,OACf,QAAO,SAAS,UAAU,QAAQ;;CAKtC,MAAM,SAAS,IAAI,IAAI,IAAI;AAC3B,KAAI,WAAW,OACb,QAAO,SAAS,QAAQ,QAAQ;AAIlC,QAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCT,SAAgB,kBAAkB,SAAsC;CAEtE,MAAM,QAAQ,OAAO,MAAM,QAAQ,CAAC;CAEpC,MAAM,UAAU,OAAO,EAAE;CAEzB,SAAS,MAAM,KAAmD;AAChE,MAAI,CAAC,IAAK,wBAAO,IAAI,KAAK;AAC1B,SAAO,IAAI,IAAI,OAAO,QAAQ,IAAI,CAAC;;CAIrC,SAAS,IAAI,KAAa,SAA4B;AAEpD,WAAS;AACT,SAAO,QAAQ,MAAM,MAAM,EAAE,KAAK,QAAQ;;AAG5C,KAAI,OAAO,KAAa,YAA+B;AACrD,SAAO,CAAC,IAAI,KAAK,QAAQ;;AAG3B,KAAI,OAAO,GAAG,SAA4B;AACxC,SAAO,KAAK,OAAO,QAAQ,IAAI,IAAI,CAAC;;AAGtC,KAAI,OAAO,GAAG,SAA4B;AACxC,SAAO,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC;;AAGrC,KAAI,OAAO,gBAAqC;AAC9C,cAAY;AACV,SAAM,IAAI,MAAM,YAAY,CAAC;AAC7B,WAAQ,QAAQ,MAAM,IAAI,EAAE;IAC5B;;AAGJ,KAAI,SAAS,gBAAqC;AAChD,cAAY;GACV,MAAM,UAAU,MAAM,MAAM;AAC5B,QAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,YAAY,CACpD,SAAQ,IAAI,KAAK,MAAM;AAEzB,SAAM,IAAI,QAAQ;AAClB,WAAQ,QAAQ,MAAM,IAAI,EAAE;IAC5B;;AAGJ,KAAI,UAAU,eAAe;AAC3B,WAAS;EACT,MAAM,OAAiB,EAAE;AACzB,OAAK,MAAM,CAAC,KAAK,UAAU,MAAM,MAAM,CAErC,KAAI,UAAU,QAAQ,OAAO,UAAU,WACrC,MAAK,KAAK,IAAI;AAGlB,SAAO;GACP;AAEF,KAAI,UAAU,eAAe;AAC3B,WAAS;AACT,SAAO,CAAC,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC;GAClC;AAEF,QAAO"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/permissions",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.5",
|
|
4
4
|
"description": "Reactive permissions for Pyreon — type-safe, signal-driven, universal",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,13 +41,13 @@
|
|
|
41
41
|
"lint": "biome check ."
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@pyreon/core": "^0.11.
|
|
45
|
-
"@pyreon/reactivity": "^0.11.
|
|
44
|
+
"@pyreon/core": "^0.11.5",
|
|
45
|
+
"@pyreon/reactivity": "^0.11.5"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@happy-dom/global-registrator": "^20.8.3",
|
|
49
|
-
"@pyreon/core": "^0.11.
|
|
50
|
-
"@pyreon/reactivity": "^0.11.
|
|
49
|
+
"@pyreon/core": "^0.11.5",
|
|
50
|
+
"@pyreon/reactivity": "^0.11.5",
|
|
51
51
|
"@vitus-labs/tools-lint": "^1.11.0"
|
|
52
52
|
}
|
|
53
53
|
}
|
package/src/permissions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed, signal } from "@pyreon/reactivity"
|
|
1
|
+
import { batch, computed, signal } from "@pyreon/reactivity"
|
|
2
2
|
import type { PermissionMap, Permissions, PermissionValue } from "./types"
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -106,17 +106,21 @@ export function createPermissions(initial?: PermissionMap): Permissions {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
can.set = (permissions: PermissionMap): void => {
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
batch(() => {
|
|
110
|
+
store.set(toMap(permissions))
|
|
111
|
+
version.update((v) => v + 1)
|
|
112
|
+
})
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
can.patch = (permissions: PermissionMap): void => {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
batch(() => {
|
|
117
|
+
const current = store.peek()
|
|
118
|
+
for (const [key, value] of Object.entries(permissions)) {
|
|
119
|
+
current.set(key, value)
|
|
120
|
+
}
|
|
121
|
+
store.set(current)
|
|
122
|
+
version.update((v) => v + 1)
|
|
123
|
+
})
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
can.granted = computed(() => {
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { computed, effect } from "@pyreon/reactivity"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { createPermissions } from "../index"
|
|
4
|
+
|
|
5
|
+
describe("createPermissions — can.all()", () => {
|
|
6
|
+
it("returns true when all permissions are granted", () => {
|
|
7
|
+
const can = createPermissions({
|
|
8
|
+
"posts.read": true,
|
|
9
|
+
"posts.create": true,
|
|
10
|
+
"posts.update": true,
|
|
11
|
+
})
|
|
12
|
+
expect(can.all("posts.read", "posts.create", "posts.update")).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("returns false when any permission is denied", () => {
|
|
16
|
+
const can = createPermissions({
|
|
17
|
+
"posts.read": true,
|
|
18
|
+
"posts.create": true,
|
|
19
|
+
"posts.delete": false,
|
|
20
|
+
})
|
|
21
|
+
expect(can.all("posts.read", "posts.create", "posts.delete")).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("returns false when any permission is undefined", () => {
|
|
25
|
+
const can = createPermissions({ "posts.read": true })
|
|
26
|
+
expect(can.all("posts.read", "posts.create")).toBe(false) // posts.create undefined
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("returns true for empty args (vacuous truth)", () => {
|
|
30
|
+
const can = createPermissions()
|
|
31
|
+
expect(can.all()).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("works with wildcards", () => {
|
|
35
|
+
const can = createPermissions({ "posts.*": true })
|
|
36
|
+
expect(can.all("posts.read", "posts.create")).toBe(true)
|
|
37
|
+
expect(can.all("posts.read", "users.manage")).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("is reactive in effects", () => {
|
|
41
|
+
const can = createPermissions({
|
|
42
|
+
"posts.read": true,
|
|
43
|
+
"posts.create": true,
|
|
44
|
+
})
|
|
45
|
+
const results: boolean[] = []
|
|
46
|
+
|
|
47
|
+
effect(() => {
|
|
48
|
+
results.push(can.all("posts.read", "posts.create"))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
can.patch({ "posts.create": false })
|
|
52
|
+
expect(results).toEqual([true, false])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("is reactive in computed", () => {
|
|
56
|
+
const can = createPermissions({
|
|
57
|
+
"posts.read": true,
|
|
58
|
+
"posts.create": true,
|
|
59
|
+
})
|
|
60
|
+
const allGranted = computed(() => can.all("posts.read", "posts.create"))
|
|
61
|
+
expect(allGranted()).toBe(true)
|
|
62
|
+
|
|
63
|
+
can.patch({ "posts.create": false })
|
|
64
|
+
expect(allGranted()).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe("createPermissions — can.any()", () => {
|
|
69
|
+
it("returns true when at least one permission is granted", () => {
|
|
70
|
+
const can = createPermissions({
|
|
71
|
+
"posts.read": false,
|
|
72
|
+
"posts.create": true,
|
|
73
|
+
})
|
|
74
|
+
expect(can.any("posts.read", "posts.create")).toBe(true)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("returns false when no permissions are granted", () => {
|
|
78
|
+
const can = createPermissions({
|
|
79
|
+
"posts.read": false,
|
|
80
|
+
"posts.create": false,
|
|
81
|
+
})
|
|
82
|
+
expect(can.any("posts.read", "posts.create")).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("returns true when all are granted", () => {
|
|
86
|
+
const can = createPermissions({
|
|
87
|
+
"posts.read": true,
|
|
88
|
+
"posts.create": true,
|
|
89
|
+
})
|
|
90
|
+
expect(can.any("posts.read", "posts.create")).toBe(true)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("returns false for empty args", () => {
|
|
94
|
+
const can = createPermissions({ "posts.read": true })
|
|
95
|
+
expect(can.any()).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("works with undefined permissions", () => {
|
|
99
|
+
const can = createPermissions({ "posts.read": true })
|
|
100
|
+
expect(can.any("posts.read", "nonexistent")).toBe(true)
|
|
101
|
+
expect(can.any("nonexistent", "also-nonexistent")).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("works with wildcards", () => {
|
|
105
|
+
const can = createPermissions({ "posts.*": true })
|
|
106
|
+
expect(can.any("posts.read", "users.manage")).toBe(true)
|
|
107
|
+
expect(can.any("users.read", "users.manage")).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("is reactive in effects", () => {
|
|
111
|
+
const can = createPermissions({
|
|
112
|
+
"posts.read": false,
|
|
113
|
+
"posts.create": true,
|
|
114
|
+
})
|
|
115
|
+
const results: boolean[] = []
|
|
116
|
+
|
|
117
|
+
effect(() => {
|
|
118
|
+
results.push(can.any("posts.read", "posts.create"))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
can.patch({ "posts.create": false })
|
|
122
|
+
expect(results).toEqual([true, false])
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe("createPermissions — can.not()", () => {
|
|
127
|
+
it("returns inverse of can()", () => {
|
|
128
|
+
const can = createPermissions({
|
|
129
|
+
"posts.read": true,
|
|
130
|
+
"posts.delete": false,
|
|
131
|
+
})
|
|
132
|
+
expect(can.not("posts.read")).toBe(false)
|
|
133
|
+
expect(can.not("posts.delete")).toBe(true)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("returns true for undefined permissions", () => {
|
|
137
|
+
const can = createPermissions()
|
|
138
|
+
expect(can.not("anything")).toBe(true)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("works with context for predicate permissions", () => {
|
|
142
|
+
const can = createPermissions({
|
|
143
|
+
"posts.update": (post: any) => post?.authorId === "me",
|
|
144
|
+
})
|
|
145
|
+
expect(can.not("posts.update", { authorId: "me" })).toBe(false)
|
|
146
|
+
expect(can.not("posts.update", { authorId: "other" })).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("is reactive", () => {
|
|
150
|
+
const can = createPermissions({ "posts.read": true })
|
|
151
|
+
const results: boolean[] = []
|
|
152
|
+
|
|
153
|
+
effect(() => {
|
|
154
|
+
results.push(can.not("posts.read"))
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
can.set({ "posts.read": false })
|
|
158
|
+
expect(results).toEqual([false, true])
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe("createPermissions — can.set()", () => {
|
|
163
|
+
it("replaces all permissions entirely", () => {
|
|
164
|
+
const can = createPermissions({
|
|
165
|
+
"posts.read": true,
|
|
166
|
+
"users.manage": true,
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
can.set({ "posts.read": false })
|
|
170
|
+
expect(can("posts.read")).toBe(false)
|
|
171
|
+
expect(can("users.manage")).toBe(false) // was not in new set
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("triggers reactive updates", () => {
|
|
175
|
+
const can = createPermissions({ "posts.read": true })
|
|
176
|
+
const results: boolean[] = []
|
|
177
|
+
|
|
178
|
+
effect(() => {
|
|
179
|
+
results.push(can("posts.read"))
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
can.set({ "posts.read": false })
|
|
183
|
+
can.set({ "posts.read": true })
|
|
184
|
+
expect(results).toEqual([true, false, true])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("set with empty object removes all permissions", () => {
|
|
188
|
+
const can = createPermissions({ "posts.read": true, "users.manage": true })
|
|
189
|
+
can.set({})
|
|
190
|
+
expect(can("posts.read")).toBe(false)
|
|
191
|
+
expect(can("users.manage")).toBe(false)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("set with predicates", () => {
|
|
195
|
+
const can = createPermissions({})
|
|
196
|
+
can.set({
|
|
197
|
+
"posts.update": (post: any) => post?.authorId === "me",
|
|
198
|
+
})
|
|
199
|
+
expect(can("posts.update", { authorId: "me" })).toBe(true)
|
|
200
|
+
expect(can("posts.update", { authorId: "other" })).toBe(false)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe("createPermissions — can.patch()", () => {
|
|
205
|
+
it("merges with existing permissions", () => {
|
|
206
|
+
const can = createPermissions({
|
|
207
|
+
"posts.read": true,
|
|
208
|
+
"users.manage": false,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
can.patch({ "users.manage": true, "billing.view": true })
|
|
212
|
+
expect(can("posts.read")).toBe(true) // unchanged
|
|
213
|
+
expect(can("users.manage")).toBe(true) // updated
|
|
214
|
+
expect(can("billing.view")).toBe(true) // added
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("overwrites existing keys", () => {
|
|
218
|
+
const can = createPermissions({ "posts.read": true })
|
|
219
|
+
can.patch({ "posts.read": false })
|
|
220
|
+
expect(can("posts.read")).toBe(false)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it("adds new keys without removing existing", () => {
|
|
224
|
+
const can = createPermissions({ a: true })
|
|
225
|
+
can.patch({ b: true })
|
|
226
|
+
expect(can("a")).toBe(true)
|
|
227
|
+
expect(can("b")).toBe(true)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it("triggers reactive updates", () => {
|
|
231
|
+
const can = createPermissions({ "posts.read": false })
|
|
232
|
+
const results: boolean[] = []
|
|
233
|
+
|
|
234
|
+
effect(() => {
|
|
235
|
+
results.push(can("posts.read"))
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
can.patch({ "posts.read": true })
|
|
239
|
+
expect(results).toEqual([false, true])
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it("patch with predicates", () => {
|
|
243
|
+
const can = createPermissions({ "posts.read": true })
|
|
244
|
+
can.patch({
|
|
245
|
+
"posts.update": (post: any) => post?.draft === true,
|
|
246
|
+
})
|
|
247
|
+
expect(can("posts.read")).toBe(true)
|
|
248
|
+
expect(can("posts.update", { draft: true })).toBe(true)
|
|
249
|
+
expect(can("posts.update", { draft: false })).toBe(false)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
describe("createPermissions — can.granted()", () => {
|
|
254
|
+
it("returns keys with true values", () => {
|
|
255
|
+
const can = createPermissions({
|
|
256
|
+
"posts.read": true,
|
|
257
|
+
"posts.delete": false,
|
|
258
|
+
"users.manage": true,
|
|
259
|
+
})
|
|
260
|
+
const granted = can.granted()
|
|
261
|
+
expect(granted).toContain("posts.read")
|
|
262
|
+
expect(granted).toContain("users.manage")
|
|
263
|
+
expect(granted).not.toContain("posts.delete")
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it("includes predicate keys (capabilities exist)", () => {
|
|
267
|
+
const can = createPermissions({
|
|
268
|
+
"posts.update": (post: any) => post?.authorId === "me",
|
|
269
|
+
})
|
|
270
|
+
expect(can.granted()).toContain("posts.update")
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it("returns empty array for no permissions", () => {
|
|
274
|
+
const can = createPermissions()
|
|
275
|
+
expect(can.granted()).toEqual([])
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it("returns empty array when all denied", () => {
|
|
279
|
+
const can = createPermissions({ a: false, b: false })
|
|
280
|
+
expect(can.granted()).toEqual([])
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it("is reactive — updates on set()", () => {
|
|
284
|
+
const can = createPermissions({ "posts.read": true })
|
|
285
|
+
const results: string[][] = []
|
|
286
|
+
|
|
287
|
+
effect(() => {
|
|
288
|
+
results.push([...can.granted()])
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
can.set({ "posts.read": true, "users.manage": true })
|
|
292
|
+
expect(results).toHaveLength(2)
|
|
293
|
+
expect(results[1]).toEqual(expect.arrayContaining(["posts.read", "users.manage"]))
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it("is reactive — updates on patch()", () => {
|
|
297
|
+
const can = createPermissions({ "posts.read": true })
|
|
298
|
+
const results: string[][] = []
|
|
299
|
+
|
|
300
|
+
effect(() => {
|
|
301
|
+
results.push([...can.granted()])
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
can.patch({ "users.manage": true })
|
|
305
|
+
expect(results).toEqual([["posts.read"], ["posts.read", "users.manage"]])
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
describe("createPermissions — can.entries()", () => {
|
|
310
|
+
it("returns all entries as [key, value] pairs", () => {
|
|
311
|
+
const can = createPermissions({
|
|
312
|
+
"posts.read": true,
|
|
313
|
+
"posts.delete": false,
|
|
314
|
+
})
|
|
315
|
+
const entries = can.entries()
|
|
316
|
+
expect(entries).toHaveLength(2)
|
|
317
|
+
expect(entries).toEqual(
|
|
318
|
+
expect.arrayContaining([
|
|
319
|
+
["posts.read", true],
|
|
320
|
+
["posts.delete", false],
|
|
321
|
+
]),
|
|
322
|
+
)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it("returns empty array for no permissions", () => {
|
|
326
|
+
const can = createPermissions()
|
|
327
|
+
expect(can.entries()).toEqual([])
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it("includes predicate entries", () => {
|
|
331
|
+
const pred = (post: any) => post?.authorId === "me"
|
|
332
|
+
const can = createPermissions({
|
|
333
|
+
"posts.update": pred,
|
|
334
|
+
})
|
|
335
|
+
const entries = can.entries()
|
|
336
|
+
expect(entries).toHaveLength(1)
|
|
337
|
+
expect(entries[0]?.[0]).toBe("posts.update")
|
|
338
|
+
expect(entries[0]?.[1]).toBe(pred)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it("is reactive — updates on mutations", () => {
|
|
342
|
+
const can = createPermissions({ a: true })
|
|
343
|
+
const counts: number[] = []
|
|
344
|
+
|
|
345
|
+
effect(() => {
|
|
346
|
+
counts.push(can.entries().length)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
can.patch({ b: true })
|
|
350
|
+
can.patch({ c: false })
|
|
351
|
+
expect(counts).toEqual([1, 2, 3])
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it("reflects set() replacement", () => {
|
|
355
|
+
const can = createPermissions({ a: true, b: true, c: true })
|
|
356
|
+
expect(can.entries()).toHaveLength(3)
|
|
357
|
+
|
|
358
|
+
can.set({ x: true })
|
|
359
|
+
expect(can.entries()).toHaveLength(1)
|
|
360
|
+
expect(can.entries()[0]?.[0]).toBe("x")
|
|
361
|
+
})
|
|
362
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { createPermissions, usePermissions } from "../index"
|
|
3
|
+
|
|
4
|
+
describe("usePermissions", () => {
|
|
5
|
+
it("throws when called outside PermissionsProvider", () => {
|
|
6
|
+
expect(() => usePermissions()).toThrow(
|
|
7
|
+
"[@pyreon/permissions] usePermissions() must be used within <PermissionsProvider>.",
|
|
8
|
+
)
|
|
9
|
+
})
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe("createPermissions used directly (no context)", () => {
|
|
13
|
+
it("works standalone without any provider", () => {
|
|
14
|
+
const can = createPermissions({ "posts.read": true })
|
|
15
|
+
expect(can("posts.read")).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("multiple independent instances do not interfere", () => {
|
|
19
|
+
const canA = createPermissions({ "posts.read": true })
|
|
20
|
+
const canB = createPermissions({ "posts.read": false })
|
|
21
|
+
|
|
22
|
+
expect(canA("posts.read")).toBe(true)
|
|
23
|
+
expect(canB("posts.read")).toBe(false)
|
|
24
|
+
|
|
25
|
+
canA.set({ "posts.read": false })
|
|
26
|
+
expect(canA("posts.read")).toBe(false)
|
|
27
|
+
expect(canB("posts.read")).toBe(false) // unchanged, already false
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("set() on one instance does not affect another", () => {
|
|
31
|
+
const canA = createPermissions({ a: true, b: true })
|
|
32
|
+
const canB = createPermissions({ a: true, b: true })
|
|
33
|
+
|
|
34
|
+
canA.set({ a: false })
|
|
35
|
+
expect(canA("a")).toBe(false)
|
|
36
|
+
expect(canA("b")).toBe(false) // cleared by set
|
|
37
|
+
|
|
38
|
+
expect(canB("a")).toBe(true) // unaffected
|
|
39
|
+
expect(canB("b")).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("patch() on one instance does not affect another", () => {
|
|
43
|
+
const canA = createPermissions({ shared: true })
|
|
44
|
+
const canB = createPermissions({ shared: true })
|
|
45
|
+
|
|
46
|
+
canA.patch({ shared: false })
|
|
47
|
+
expect(canA("shared")).toBe(false)
|
|
48
|
+
expect(canB("shared")).toBe(true) // unaffected
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { signal } from "@pyreon/reactivity"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { createPermissions } from "../index"
|
|
4
|
+
|
|
5
|
+
describe("createPermissions — predicate permissions", () => {
|
|
6
|
+
describe("basic predicate evaluation", () => {
|
|
7
|
+
it("evaluates predicate with context argument", () => {
|
|
8
|
+
const can = createPermissions({
|
|
9
|
+
"posts.update": (post: any) => post?.authorId === "user-1",
|
|
10
|
+
})
|
|
11
|
+
expect(can("posts.update", { authorId: "user-1" })).toBe(true)
|
|
12
|
+
expect(can("posts.update", { authorId: "user-2" })).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("predicate called without context receives undefined", () => {
|
|
16
|
+
const can = createPermissions({
|
|
17
|
+
"posts.update": (post?: any) => post?.authorId === "user-1",
|
|
18
|
+
})
|
|
19
|
+
// No context — post is undefined
|
|
20
|
+
expect(can("posts.update")).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("predicate that ignores context (always true)", () => {
|
|
24
|
+
const can = createPermissions({
|
|
25
|
+
"posts.create": () => true,
|
|
26
|
+
})
|
|
27
|
+
expect(can("posts.create")).toBe(true)
|
|
28
|
+
expect(can("posts.create", { anything: true })).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("predicate that ignores context (always false)", () => {
|
|
32
|
+
const can = createPermissions({
|
|
33
|
+
"posts.create": () => false,
|
|
34
|
+
})
|
|
35
|
+
expect(can("posts.create")).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe("complex predicate logic", () => {
|
|
40
|
+
it("ownership check", () => {
|
|
41
|
+
const currentUserId = "user-42"
|
|
42
|
+
const can = createPermissions({
|
|
43
|
+
"posts.update": (post: any) => post?.authorId === currentUserId,
|
|
44
|
+
"posts.delete": (post: any) => post?.authorId === currentUserId && post?.status === "draft",
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const myDraft = { authorId: "user-42", status: "draft" }
|
|
48
|
+
const myPublished = { authorId: "user-42", status: "published" }
|
|
49
|
+
const otherPost = { authorId: "user-99", status: "draft" }
|
|
50
|
+
|
|
51
|
+
expect(can("posts.update", myDraft)).toBe(true)
|
|
52
|
+
expect(can("posts.update", myPublished)).toBe(true)
|
|
53
|
+
expect(can("posts.update", otherPost)).toBe(false)
|
|
54
|
+
expect(can("posts.delete", myDraft)).toBe(true)
|
|
55
|
+
expect(can("posts.delete", myPublished)).toBe(false)
|
|
56
|
+
expect(can("posts.delete", otherPost)).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("predicate with multiple conditions", () => {
|
|
60
|
+
const can = createPermissions({
|
|
61
|
+
"orders.refund": (order: any) => {
|
|
62
|
+
if (!order) return false
|
|
63
|
+
const isRecent = Date.now() - order.createdAt < 86400000
|
|
64
|
+
const isSmall = order.amount < 100
|
|
65
|
+
return isRecent && isSmall
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const recentSmall = { createdAt: Date.now() - 1000, amount: 50 }
|
|
70
|
+
const recentLarge = { createdAt: Date.now() - 1000, amount: 200 }
|
|
71
|
+
const oldSmall = { createdAt: Date.now() - 100_000_000, amount: 50 }
|
|
72
|
+
|
|
73
|
+
expect(can("orders.refund", recentSmall)).toBe(true)
|
|
74
|
+
expect(can("orders.refund", recentLarge)).toBe(false)
|
|
75
|
+
expect(can("orders.refund", oldSmall)).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("predicate referencing reactive signal", () => {
|
|
79
|
+
const role = signal("admin")
|
|
80
|
+
const can = createPermissions({
|
|
81
|
+
"users.manage": () => role.peek() === "admin",
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
expect(can("users.manage")).toBe(true)
|
|
85
|
+
|
|
86
|
+
role.set("viewer")
|
|
87
|
+
// Need to trigger version update for reactive tracking,
|
|
88
|
+
// but predicate re-evaluation on read still reflects signal state
|
|
89
|
+
can.patch({ "users.manage": () => role.peek() === "admin" })
|
|
90
|
+
expect(can("users.manage")).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe("predicate error handling", () => {
|
|
95
|
+
it("returns false when predicate throws", () => {
|
|
96
|
+
const can = createPermissions({
|
|
97
|
+
"posts.update": () => {
|
|
98
|
+
throw new Error("boom")
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
expect(can("posts.update")).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("returns false when predicate throws with context", () => {
|
|
105
|
+
const can = createPermissions({
|
|
106
|
+
"posts.update": (_post: any) => {
|
|
107
|
+
throw new TypeError("property access failed")
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
expect(can("posts.update", { id: 1 })).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("can.not returns true when predicate throws", () => {
|
|
114
|
+
const can = createPermissions({
|
|
115
|
+
"posts.update": () => {
|
|
116
|
+
throw new Error("error")
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
expect(can.not("posts.update")).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("other permissions unaffected when one predicate throws", () => {
|
|
123
|
+
const can = createPermissions({
|
|
124
|
+
"posts.read": true,
|
|
125
|
+
"posts.update": () => {
|
|
126
|
+
throw new Error("boom")
|
|
127
|
+
},
|
|
128
|
+
"posts.create": () => true,
|
|
129
|
+
})
|
|
130
|
+
expect(can("posts.read")).toBe(true)
|
|
131
|
+
expect(can("posts.update")).toBe(false)
|
|
132
|
+
expect(can("posts.create")).toBe(true)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe("predicates in granted()", () => {
|
|
137
|
+
it("predicates count as capabilities in granted()", () => {
|
|
138
|
+
const can = createPermissions({
|
|
139
|
+
"posts.read": true,
|
|
140
|
+
"posts.update": (post: any) => post?.authorId === "me",
|
|
141
|
+
"posts.delete": false,
|
|
142
|
+
})
|
|
143
|
+
const granted = can.granted()
|
|
144
|
+
expect(granted).toContain("posts.read")
|
|
145
|
+
expect(granted).toContain("posts.update") // predicate = capability exists
|
|
146
|
+
expect(granted).not.toContain("posts.delete")
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { effect } from "@pyreon/reactivity"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { createPermissions } from "../index"
|
|
4
|
+
|
|
5
|
+
describe("createPermissions — wildcard matching", () => {
|
|
6
|
+
describe("prefix wildcard (posts.*)", () => {
|
|
7
|
+
it("matches any sub-key under the prefix", () => {
|
|
8
|
+
const can = createPermissions({ "posts.*": true })
|
|
9
|
+
expect(can("posts.read")).toBe(true)
|
|
10
|
+
expect(can("posts.create")).toBe(true)
|
|
11
|
+
expect(can("posts.update")).toBe(true)
|
|
12
|
+
expect(can("posts.delete")).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("does not match keys without a dot", () => {
|
|
16
|
+
const can = createPermissions({ "posts.*": true })
|
|
17
|
+
expect(can("posts")).toBe(false) // no dot, no wildcard match
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("does not match different prefix", () => {
|
|
21
|
+
const can = createPermissions({ "posts.*": true })
|
|
22
|
+
expect(can("users.read")).toBe(false)
|
|
23
|
+
expect(can("comments.read")).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("does not match partial prefix overlap", () => {
|
|
27
|
+
const can = createPermissions({ "post.*": true })
|
|
28
|
+
expect(can("posts.read")).toBe(false) // 'posts' !== 'post'
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("exact match takes precedence over wildcard", () => {
|
|
32
|
+
const can = createPermissions({
|
|
33
|
+
"posts.*": true,
|
|
34
|
+
"posts.delete": false,
|
|
35
|
+
})
|
|
36
|
+
expect(can("posts.read")).toBe(true)
|
|
37
|
+
expect(can("posts.create")).toBe(true)
|
|
38
|
+
expect(can("posts.delete")).toBe(false) // exact overrides wildcard
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("wildcard with predicate function", () => {
|
|
42
|
+
const can = createPermissions({
|
|
43
|
+
"posts.*": (post: any) => post?.status !== "archived",
|
|
44
|
+
})
|
|
45
|
+
expect(can("posts.read", { status: "published" })).toBe(true)
|
|
46
|
+
expect(can("posts.update", { status: "archived" })).toBe(false)
|
|
47
|
+
expect(can("posts.delete", { status: "draft" })).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("multiple prefix wildcards for different namespaces", () => {
|
|
51
|
+
const can = createPermissions({
|
|
52
|
+
"posts.*": true,
|
|
53
|
+
"users.*": false,
|
|
54
|
+
})
|
|
55
|
+
expect(can("posts.read")).toBe(true)
|
|
56
|
+
expect(can("users.read")).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe("global wildcard (*)", () => {
|
|
61
|
+
it("matches any key", () => {
|
|
62
|
+
const can = createPermissions({ "*": true })
|
|
63
|
+
expect(can("posts.read")).toBe(true)
|
|
64
|
+
expect(can("users.manage")).toBe(true)
|
|
65
|
+
expect(can("anything.at.all")).toBe(true)
|
|
66
|
+
expect(can("simple-key")).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("exact match takes precedence over global wildcard", () => {
|
|
70
|
+
const can = createPermissions({
|
|
71
|
+
"*": true,
|
|
72
|
+
"billing.export": false,
|
|
73
|
+
})
|
|
74
|
+
expect(can("posts.read")).toBe(true)
|
|
75
|
+
expect(can("billing.export")).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("prefix wildcard takes precedence over global wildcard", () => {
|
|
79
|
+
const can = createPermissions({
|
|
80
|
+
"*": false,
|
|
81
|
+
"posts.*": true,
|
|
82
|
+
})
|
|
83
|
+
expect(can("posts.read")).toBe(true)
|
|
84
|
+
expect(can("posts.create")).toBe(true)
|
|
85
|
+
expect(can("users.manage")).toBe(false) // falls to global *
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("resolution order: exact > prefix wildcard > global wildcard", () => {
|
|
89
|
+
const can = createPermissions({
|
|
90
|
+
"*": false,
|
|
91
|
+
"posts.*": true,
|
|
92
|
+
"posts.delete": false,
|
|
93
|
+
})
|
|
94
|
+
expect(can("posts.read")).toBe(true) // matched by posts.*
|
|
95
|
+
expect(can("posts.delete")).toBe(false) // exact match
|
|
96
|
+
expect(can("users.manage")).toBe(false) // matched by *
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("global wildcard with predicate", () => {
|
|
100
|
+
const can = createPermissions({
|
|
101
|
+
"*": (ctx: any) => ctx?.role === "admin",
|
|
102
|
+
})
|
|
103
|
+
expect(can("posts.read", { role: "admin" })).toBe(true)
|
|
104
|
+
expect(can("posts.read", { role: "viewer" })).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe("wildcard reactivity", () => {
|
|
109
|
+
it("wildcards are reactive after set()", () => {
|
|
110
|
+
const can = createPermissions({ "posts.*": false })
|
|
111
|
+
const results: boolean[] = []
|
|
112
|
+
|
|
113
|
+
effect(() => {
|
|
114
|
+
results.push(can("posts.read"))
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
can.set({ "posts.*": true })
|
|
118
|
+
expect(results).toEqual([false, true])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("wildcards are reactive after patch()", () => {
|
|
122
|
+
const can = createPermissions({ "posts.*": true })
|
|
123
|
+
const results: boolean[] = []
|
|
124
|
+
|
|
125
|
+
effect(() => {
|
|
126
|
+
results.push(can("posts.read"))
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
can.patch({ "posts.read": false }) // exact override
|
|
130
|
+
expect(results).toEqual([true, false])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("removing wildcard via set() removes its effect", () => {
|
|
134
|
+
const can = createPermissions({ "posts.*": true })
|
|
135
|
+
expect(can("posts.read")).toBe(true)
|
|
136
|
+
|
|
137
|
+
can.set({}) // remove all permissions
|
|
138
|
+
expect(can("posts.read")).toBe(false)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
})
|