diff --git a/README.md b/README.md index 44439ce..3092782 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Axios is required when using the Axios transport (the default) or the `/axios` entry point. Projects using the native transport can omit Axios: generate with `--restClient native` and import `NativeRestClient` from `@povio/openapi-codegen-cli/native`. The root exports remain available for compatibility and support tree-shaking in consumer bundles. For direct runtime imports without bundling, use `/native` and `/errors` to avoid loading the Axios client. The `/axios` entry point is also available for Axios-specific imports. CLI-only usage and the `/generator`, `/tiny`, `/vite`, and `/metro` entry points do not require Axios. +ACL features require `@casl/ability@^7.1.1` and `@casl/react@^7.1.0`. When migrating to CASL 7, regenerate the client so `AppAbility` uses `Ability` instead of `PureAbility`, and update any handwritten CASL types. The runtime continues to use `createMongoAbility` for condition matching. CASL 7 treats empty conditions (`{}`) as matching everything. The `Can` helper keeps its `use` tuple and `(isAllowed, ability)` render callback. + Use this tool to generate code (Zod schemas, TypeScript types, API definitions, and React queries) from an OpenAPI v3 specification. API definitions use a REST client wrapper with either Axios or the native transport. React queries are generated in alignment with our code standards, without the need for explicit types. The tool partially leverages code from [openapi-zod-client](https://github.com/astahmer/openapi-zod-client) repository. diff --git a/bun.lock b/bun.lock index 906e873..1e93b27 100644 --- a/bun.lock +++ b/bun.lock @@ -15,13 +15,14 @@ "yargs": "^18.0.0", }, "devDependencies": { - "@casl/ability": "6.8.0", - "@casl/react": "5.0.1", + "@casl/ability": "7.1.1", + "@casl/react": "7.1.0", "@tanstack/react-query": "~5.90.21", "@types/bun": "1.4.1", "@types/node": "^25.3.0", "@types/prompt-sync": "^4.2.3", "@types/react": "^19.2.14", + "@types/react-test-renderer": "^19.1.0", "@types/yargs": "^17.0.35", "@vitejs/plugin-react": "^5.1.4", "axios": "^1.13.1", @@ -30,6 +31,7 @@ "oxlint": "1.50.0", "oxlint-tsgolint": "^0.15.0", "react": "^19.2.4", + "react-test-renderer": "19.2.4", "tsdown": "^0.22.14", "tsx": "^4.21.0", "type-fest": "^5.4.4", @@ -40,8 +42,8 @@ "zod": "^4.5.0", }, "peerDependencies": { - "@casl/ability": "^6.7.3", - "@casl/react": "^5.0.0", + "@casl/ability": "^7.1.1", + "@casl/react": "^7.1.0", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.1", "i18next": "^25.8.11", @@ -101,9 +103,9 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], - "@casl/ability": ["@casl/ability@6.8.0", "", { "dependencies": { "@ucast/mongo2js": "^1.3.0" } }, "sha512-Ipt4mzI4gSgnomFdaPjaLgY2MWuXqAEZLrU6qqWBB7khGiBBuuEp6ytYDnq09bRXqcjaeeHiaCvCGFbBA2SpvA=="], + "@casl/ability": ["@casl/ability@7.1.1", "", { "dependencies": { "@ucast/mongo2js": "^2.0.0" } }, "sha512-1WL90aW65z7RyaoFhOrfUua6e8gk5I2DS7WDjTTI5/qtGnFcnesX4LvfXkqO21LexTswLq71s4Lfs93eI45z8A=="], - "@casl/react": ["@casl/react@5.0.1", "", { "peerDependencies": { "@casl/ability": "^4.0.0 || ^5.1.0 || ^6.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8E3GkvwlxEW+bkxWTdvjn6SALNJZa9khNk591xTpU27nPK/nzgfqsVfEvrFjYIQ2LODrxXsWLmw7iHPEI0/TaQ=="], + "@casl/react": ["@casl/react@7.1.0", "", { "peerDependencies": { "@casl/ability": "^4.0.0 || ^5.1.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0" } }, "sha512-z0TkQNReAGGOGhPhtfrLBB2uJTxS2b55rBZH4aEw7dYUQQSYMj688NwCT75SwfyiKFnmwn0+B+II/DsoJKNe4w=="], "@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], @@ -385,6 +387,8 @@ "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + "@types/react-test-renderer": ["@types/react-test-renderer@19.3.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-TX4p2D4Z2oigM3SXU7+QyLSUnSR0vR1S1Pfxm/qFL9nHQ3jclgi6ioGoWQmlIr1DeKRVmPJrmNlhgM0JlWlLUg=="], + "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], @@ -429,13 +433,13 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@ucast/core": ["@ucast/core@1.10.2", "", {}, "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g=="], + "@ucast/core": ["@ucast/core@2.0.0", "", {}, "sha512-4XVx6LzPXZGvnZO5jp39cm/G4UvuwvEdtmg+9+4+zl6uFkCcB7UJacvtMYeBE56GJVT99Zqy6Pii7dGJq3Kz9Q=="], - "@ucast/js": ["@ucast/js@3.1.0", "", { "dependencies": { "@ucast/core": "1.10.2" } }, "sha512-eJ7yQeYtMK85UZjxoxBEbTWx6UMxEXKbjVyp+NlzrT5oMKV5Gpo/9bjTl3r7msaXTVC8iD9NJacqJ8yp7joX+Q=="], + "@ucast/js": ["@ucast/js@4.0.1", "", { "dependencies": { "@ucast/core": "2.0.0" } }, "sha512-9O5xPBvwEWQk2WvO69Eh2WJB8QljVZ2vRVdFvfnKjlZwWXcYxp1lqLBhwXBU1AtuSgCvKhJPkXdjKJggUmAmQQ=="], - "@ucast/mongo": ["@ucast/mongo@2.4.3", "", { "dependencies": { "@ucast/core": "^1.4.1" } }, "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA=="], + "@ucast/mongo": ["@ucast/mongo@3.0.0", "", { "dependencies": { "@ucast/core": "2.0.0" } }, "sha512-kwuSH+kdB4GCR0LGhy/PEDm4PCflur89AlK82kNiYD0FvsA8A/p+0sx7m+/R8mMFAlmlkAd3VXp7sM/cLLYWYg=="], - "@ucast/mongo2js": ["@ucast/mongo2js@1.4.1", "", { "dependencies": { "@ucast/core": "1.10.2", "@ucast/js": "3.1.0", "@ucast/mongo": "2.4.3" } }, "sha512-9aeg5cmqwRQnKCXHN6I17wk83Rcm487bHelaG8T4vfpWneAI469wSI3Srnbu+PuZ5znWRbnwtVq9RgPL+bN6CA=="], + "@ucast/mongo2js": ["@ucast/mongo2js@2.0.0", "", { "dependencies": { "@ucast/core": "2.0.0", "@ucast/js": "4.0.1", "@ucast/mongo": "3.0.0" } }, "sha512-vNBZzRnsfLr/TSxEoxz6W6hHQ5tmWsfEeC0nCq5z8RezC1AqIRy3cfHm8AGvlGtcn+cTSFQcZremfqnz6wm+nQ=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], @@ -775,8 +779,12 @@ "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], + "react-is": ["react-is@19.3.1", "", {}, "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "react-test-renderer": ["react-test-renderer@19.2.4", "", { "dependencies": { "react-is": "^19.2.4", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-Ttl5D7Rnmi6JGMUpri4UjB4BAN0FPs4yRDnu2XSsigCWOLm11o8GwRlVsh27ER+4WFqsGtrBuuv5zumUaRCmKw=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -791,6 +799,8 @@ "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], diff --git a/native/src/render.rs b/native/src/render.rs index 47fb357..bb406f3 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -2428,7 +2428,7 @@ fn render_app_acl(endpoints: &[Value]) -> Option { } let forced = if additional { ", ForcedSubject" } else { "" }; let mut lines = vec![ - format!("import type {{ PureAbility, AbilityTuple{forced} }} from \"@casl/ability\";"), + format!("import type {{ Ability, AbilityTuple{forced} }} from \"@casl/ability\";"), String::new(), "export type AppAbilities = ".into(), ]; @@ -2439,7 +2439,7 @@ fn render_app_acl(endpoints: &[Value]) -> Option { )); } lines.push(String::new()); - lines.push("export type AppAbility = PureAbility;".into()); + lines.push("export type AppAbility = Ability;".into()); Some(lines.join("\n")) } diff --git a/package.json b/package.json index 806ae67..4d01003 100644 --- a/package.json +++ b/package.json @@ -125,13 +125,14 @@ "yargs": "^18.0.0" }, "devDependencies": { - "@casl/ability": "6.8.0", - "@casl/react": "5.0.1", + "@casl/ability": "7.1.1", + "@casl/react": "7.1.0", "@tanstack/react-query": "~5.90.21", "@types/bun": "1.4.1", "@types/node": "^25.3.0", "@types/prompt-sync": "^4.2.3", "@types/react": "^19.2.14", + "@types/react-test-renderer": "^19.1.0", "@types/yargs": "^17.0.35", "@vitejs/plugin-react": "^5.1.4", "axios": "^1.13.1", @@ -140,6 +141,7 @@ "oxlint": "1.50.0", "oxlint-tsgolint": "^0.15.0", "react": "^19.2.4", + "react-test-renderer": "19.2.4", "tsdown": "^0.22.14", "tsx": "^4.21.0", "type-fest": "^5.4.4", @@ -150,8 +152,8 @@ "zod": "^4.5.0" }, "peerDependencies": { - "@casl/ability": "^6.7.3", - "@casl/react": "^5.0.0", + "@casl/ability": "^7.1.1", + "@casl/react": "^7.1.0", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.1", "i18next": "^25.8.11", diff --git a/src/generators/const/acl.const.ts b/src/generators/const/acl.const.ts index 9c0cff1..7cee707 100644 --- a/src/generators/const/acl.const.ts +++ b/src/generators/const/acl.const.ts @@ -7,7 +7,7 @@ export const ACL_CHECK_HOOK = "useAclCheck"; export const CASL_ABILITY_BINDING = { abilityTuple: "AbilityTuple", - pureAbility: "PureAbility", + ability: "Ability", forcedSubject: "ForcedSubject", subjectType: "Subject", subject: "subject", @@ -15,7 +15,7 @@ export const CASL_ABILITY_BINDING = { export const CASL_ABILITY_IMPORT: Import = { bindings: [ CASL_ABILITY_BINDING.abilityTuple, - CASL_ABILITY_BINDING.pureAbility, + CASL_ABILITY_BINDING.ability, CASL_ABILITY_BINDING.forcedSubject, CASL_ABILITY_BINDING.subjectType, CASL_ABILITY_BINDING.subject, diff --git a/src/generators/generate/generateAcl.ts b/src/generators/generate/generateAcl.ts index f0bf321..7981eda 100644 --- a/src/generators/generate/generateAcl.ts +++ b/src/generators/generate/generateAcl.ts @@ -82,7 +82,7 @@ export function generateAppAcl({ resolver, data }: Omit;`); + lines.push(`export type AppAbility = ${CASL_ABILITY_BINDING.ability}<${ACL_APP_ABILITIES}>;`); return lines.join("\n"); } diff --git a/src/generators/generated-casl.test.ts b/src/generators/generated-casl.test.ts new file mode 100644 index 0000000..ae8100c --- /dev/null +++ b/src/generators/generated-casl.test.ts @@ -0,0 +1,122 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import type { OpenAPIV3 } from "openapi-types"; +import { describe, expect, it } from "vitest"; + +import { compileNativeData } from "../native/native-bindings"; + +import { DEFAULT_GENERATE_OPTIONS } from "./const/options.const"; +import { generateCodeFromOpenAPIDoc } from "./generateCodeFromOpenAPIDoc"; + +function fixture(withAcl: boolean): OpenAPIV3.Document { + return { + openapi: "3.0.3", + info: { title: "CASL 7", version: "1" }, + paths: { + "/items/{workspaceId}": { + get: { + tags: ["items"], + operationId: "readItem", + parameters: [{ name: "workspaceId", in: "path", required: true, schema: { type: "string" } }], + ...(withAcl + ? { "x-acl": [{ action: "read", subject: "Item", conditions: { workspaceId: "$params.workspaceId" } }] } + : {}), + responses: { "204": { description: "OK" } }, + }, + }, + }, + } as OpenAPIV3.Document; +} + +describe("generated CASL 7 ACL", () => { + it.each([false, true])( + "compiles and executes generated ACL (withAcl=%s)", + async (withAcl) => { + const output = await mkdtemp(join(process.cwd(), ".casl-generated-")); + try { + const document = fixture(withAcl); + const source = JSON.stringify(document); + const nativeSource = process.env.OPENAPI_CODEGEN_NATIVE === "1" ? { source, yaml: false } : undefined; + const options = { + ...DEFAULT_GENERATE_OPTIONS, + output, + acl: true, + checkAcl: false, + tsNamespaces: false, + importPath: "relative" as const, + }; + const files = generateCodeFromOpenAPIDoc(document, options, undefined, nativeSource).filter((file) => + /app\.ability\.ts$|\.acl\.ts$/.test(file.fileName), + ); + const appAbility = files.find((file) => file.fileName.endsWith("app.ability.ts")); + expect(appAbility).toBeDefined(); + expect(appAbility!.content).not.toContain("PureAbility"); + if (nativeSource && withAcl) { + const native = compileNativeData(source, false, JSON.stringify(options)).data as { + renderedShared: Record; + }; + expect(native.renderedShared.appAcl).toContain("AppAbility = Ability<"); + expect(appAbility!.content).toBe(native.renderedShared.appAcl); + } + const domainAcl = files.find((file) => file.fileName.endsWith(".acl.ts")); + expect(Boolean(domainAcl)).toBe(withAcl); + for (const file of files) { + await mkdir(dirname(file.fileName), { recursive: true }); + await writeFile(file.fileName, file.content); + } + await writeFile( + join(output, "consumer.ts"), + ` +import { strict as assert } from "node:assert"; +import { AbilityBuilder, createMongoAbility } from "@casl/ability"; +import { packRules, unpackRules } from "@casl/ability/extra"; +import type { AppAbility } from "./acl/app.ability"; +${withAcl ? 'import { canUseReadItem } from "./items/items.acl";' : ""} +const builder = new AbilityBuilder(createMongoAbility); +builder.can("read", "Item", { workspaceId: "mine" }); +const ability = builder.build(); +ability.update(unpackRules(packRules(ability.rules))); +assert.equal(ability.can("read", "Item"), true); +${withAcl ? "" : 'assert.equal(ability.can("delete", "Item"), false);'} +${ + withAcl + ? ` +assert.equal(ability.can(...canUseReadItem({ workspaceId: "mine" })), true); +assert.equal(ability.can(...canUseReadItem({ workspaceId: "other" })), false); +// @ts-expect-error Generated abilities restrict actions. +const invalid: Parameters[0] = "delete"; +void invalid; +` + : "" +} +`, + ); + await writeFile( + join(output, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + target: "ESNext", + module: "ESNext", + moduleResolution: "bundler", + skipLibCheck: true, + types: ["node"], + }, + include: ["**/*.ts"], + }), + ); + execFileSync("bun", ["run", "tsc", "-p", join(output, "tsconfig.json")], { + encoding: "utf8", + stdio: "inherit", + }); + execFileSync("bun", ["run", join(output, "consumer.ts")], { encoding: "utf8", stdio: "inherit" }); + } finally { + await rm(output, { recursive: true, force: true }); + } + }, + 30_000, + ); +}); diff --git a/src/lib/acl/Can.tsx b/src/lib/acl/Can.tsx index 4cf2efe..d9e70c2 100644 --- a/src/lib/acl/Can.tsx +++ b/src/lib/acl/Can.tsx @@ -1,19 +1,26 @@ -import type { AbilityTuple, PureAbility } from "@casl/ability"; -import { type BoundCanProps, createContextualCan } from "@casl/react"; +import type { ReactNode } from "react"; -import { AbilityContext } from "./ability.context"; -import type { AppAbilities } from "./appAbility.types"; +import { Can as CaslCan, type CanProps as CaslCanProps } from "@casl/react"; -type CanAbility = PureAbility>; - -const ContextualCan = createContextualCan(AbilityContext.Consumer); +import type { AppAbilities, AppAbility } from "./appAbility.types"; type CanProps = { use: TAppAbilities; -} & Omit, "do" | "I" | "on" | "a" | "an" | "this">; + children: ReactNode | ((isAllowed: boolean, ability: AppAbility) => ReactNode); +} & Omit, "do" | "I" | "on" | "a" | "an" | "this" | "children">; -export const Can = ({ use, ...props }: CanProps) => { +export const Can = ({ + use, + children, + ...props +}: CanProps) => { const [action, subject] = use; - return ; + return ( + {...props} do={action} on={subject}> + {typeof children === "function" + ? ({ isAllowed, ability }) => (props.passThrough || isAllowed ? children(isAllowed, ability) : null) + : children} + + ); }; diff --git a/src/lib/acl/ability.context.tsx b/src/lib/acl/ability.context.tsx index 9e4607b..4ecac18 100644 --- a/src/lib/acl/ability.context.tsx +++ b/src/lib/acl/ability.context.tsx @@ -1,8 +1,8 @@ import { type PropsWithChildren, createContext, useMemo } from "react"; -import { AbilityBuilder, type PureAbility, type RawRuleOf, createMongoAbility } from "@casl/ability"; +import { AbilityBuilder, type Ability, type RawRuleOf, createMongoAbility } from "@casl/ability"; import { type PackRule, unpackRules } from "@casl/ability/extra"; -import { useAbility as useCaslAbility } from "@casl/react"; +import { AbilityProvider, useAbility as useCaslAbility } from "@casl/react"; import { AuthContext } from "../auth/auth.context"; import type { AppAbilities, AppAbility } from "./appAbility.types"; @@ -36,11 +36,15 @@ export namespace AbilityContext { return build(); }, [user]); - return {children}; + return ( + + {children} + + ); }; export const useAbility = () => { - const ability = useCaslAbility(Context); - return ability as PureAbility; + const ability = useCaslAbility(); + return ability as Ability; }; } diff --git a/src/lib/acl/ability.test.ts b/src/lib/acl/ability.test.ts new file mode 100644 index 0000000..707cb99 --- /dev/null +++ b/src/lib/acl/ability.test.ts @@ -0,0 +1,98 @@ +import { createElement } from "react"; + +import { subject, type RawRuleOf } from "@casl/ability"; +import { packRules } from "@casl/ability/extra"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it } from "vitest"; + +import { AuthContext } from "../auth/auth.context"; +import { AbilityContext } from "./ability.context"; +import { Can } from "./Can"; +import type { AppAbility } from "./appAbility.types"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const noop = () => {}; + +describe("CASL 7 runtime integration", () => { + it("provides the same ability to hooks, Consumer and Can, and updates packed conditional rules", async () => { + let ability!: AppAbility; + let consumedAbility!: AppAbility; + const callbacks: boolean[] = []; + function Probe() { + ability = AbilityContext.useAbility(); + return createElement(AbilityContext.Consumer, { + children: (value: AppAbility) => { + consumedAbility = value; + return createElement(Can, { + use: ["read", subject("Item", { workspaceId: "mine" })], + passThrough: true, + children: (isAllowed: boolean, currentAbility: AppAbility) => { + expect(currentAbility).toBe(ability); + callbacks.push(isAllowed); + return createElement("span", null, String(isAllowed)); + }, + }); + }, + }); + } + const tree = (rules: RawRuleOf[]) => + createElement(AuthContext.Provider, { + isAuthenticated: true, + isInitializing: false, + logout: noop, + user: { aclRules: packRules(rules) }, + children: createElement(AbilityContext.Provider, { children: createElement(Probe) }), + }); + let renderer!: ReactTestRenderer; + try { + await act(async () => { + renderer = create(tree([{ action: "read", subject: "Item", conditions: { workspaceId: "mine" } }])); + }); + expect(consumedAbility).toBe(ability); + expect(ability.can("read", subject("Item", { workspaceId: "mine" }))).toBe(true); + expect(ability.can("read", subject("Item", { workspaceId: "other" }))).toBe(false); + expect(renderer.toJSON()).toMatchObject({ children: ["true"] }); + await act(async () => renderer.update(tree([]))); + expect(renderer.toJSON()).toMatchObject({ children: ["false"] }); + expect(callbacks).toContain(true); + expect(callbacks.at(-1)).toBe(false); + await act(async () => renderer.update(tree([{ action: "read", subject: "Item", conditions: {} }]))); + expect(ability.can("read", subject("Item", { workspaceId: "other" }))).toBe(true); + expect(renderer.toJSON()).toMatchObject({ children: ["true"] }); + await act(async () => { + ability.update([]); + }); + expect(renderer.toJSON()).toMatchObject({ children: ["false"] }); + expect(consumedAbility).toBe(ability); + } finally { + await act(async () => renderer?.unmount()); + } + }); + + it("renders Can children for allow, deny, and not checks", async () => { + let renderer!: ReactTestRenderer; + try { + await act(async () => { + renderer = create( + createElement(AuthContext.Provider, { + isAuthenticated: true, + isInitializing: false, + logout: noop, + user: { aclRules: packRules([{ action: "read", subject: "Item" }]) }, + children: createElement(AbilityContext.Provider, { + children: [ + createElement(Can, { key: "allow", use: ["read", "Item"], children: "allowed" }), + createElement(Can, { key: "deny", use: ["delete", "Item"], children: "denied" }), + createElement(Can, { key: "not", use: ["delete", "Item"], not: true, children: "inverted" }), + ], + }), + }), + ); + }); + expect(renderer.toJSON()).toEqual(["allowed", "inverted"]); + } finally { + await act(async () => renderer?.unmount()); + } + }); +}); diff --git a/src/lib/acl/appAbility.types.ts b/src/lib/acl/appAbility.types.ts index 4a63911..0ce3806 100644 --- a/src/lib/acl/appAbility.types.ts +++ b/src/lib/acl/appAbility.types.ts @@ -1,5 +1,5 @@ -import type { AbilityTuple, PureAbility, Subject } from "@casl/ability"; +import type { AbilityTuple, Ability, Subject } from "@casl/ability"; export type AppAbilities = AbilityTuple; -export type AppAbility = PureAbility; +export type AppAbility = Ability;