Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(@casl/react): Pass forbidden reason message to child render function #973

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
40 changes: 40 additions & 0 deletions packages/casl-ability/spec/error.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,51 @@ describe('`ForbiddenError` class', () => {
expect(() => error.throwUnlessCan('archive', 'Post')).toThrow(ForbiddenError as unknown as Error)
})

it('does not raise forbidden exception on disallowed action when inverted', () => {
const { error } = setup()
expect(() => error.throwUnlessCannot('archive', 'Post')).not.toThrow(ForbiddenError as unknown as Error)
})

it('does not raise forbidden exception on allowed action', () => {
const { error } = setup()
expect(() => error.throwUnlessCan('read', 'Post')).not.toThrow(ForbiddenError as unknown as Error)
})

it('does not produce error on allowed action', () => {
const { error } = setup()
expect(error.unlessCan('read', 'Post')).toBeUndefined()
})

it('does not produce error on forbidden action when inverted', () => {
const { error } = setup()
expect(error.unlessCannot('archive', 'Post')).toBeUndefined()
})

it('produces an error on allowed action when inverted', () => {
const { error } = setup()
expect(error.unlessCannot('read', 'Post')).not.toBeUndefined()
})

it("error is inverted when producing the error via 'unlessCannot'", () => {
const { error } = setup()
expect(error.unlessCannot('read', 'Post')?.inverted).toBe(true)
})

it("error is not inverted when producing the error via 'unlessCan'", () => {
const { error } = setup()
expect(error.unlessCan('archive', 'Post')?.inverted).toBe(false)
})

it('produces an error on forbidden action', () => {
const { error } = setup()
expect(error.unlessCan('archive', 'Post')).not.toBeUndefined()
})

it('raises forbidden exception on allowed action when inverted', () => {
const { error } = setup()
expect(() => error.throwUnlessCannot('read', 'Post')).toThrow(ForbiddenError as unknown as Error)
})

it('raises error with context information', () => {
let thrownError: ForbiddenError<PureAbility> | undefined
const { error } = setup()
Expand Down
107 changes: 83 additions & 24 deletions packages/casl-ability/src/ForbiddenError.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,45 @@
import { AnyAbility } from './PureAbility';
import { Normalize, Subject } from './types';
import { Generics } from './RuleIndex';
import { getSubjectTypeName } from './utils';
import { AnyAbility } from "./PureAbility";
import { Generics } from "./RuleIndex";
import { Normalize, Subject } from "./types";
import { getSubjectTypeName } from "./utils";

export type GetErrorMessage = (error: ForbiddenError<AnyAbility>) => string;
export type GetErrorMessage = (error: ForbiddenError<AnyAbility>, inverted?: boolean) => string;
/** @deprecated will be removed in the next major release */
export const getDefaultErrorMessage: GetErrorMessage = error => `Cannot execute "${error.action}" on "${error.subjectType}"`;
export const getDefaultErrorMessage: GetErrorMessage = (error) =>
`${error.inverted ? 'Can' : "Cannot"} execute "${error.action}" on "${error.subjectType}"`;

const NativeError = function NError(this: Error, message: string) {
this.message = message;
} as unknown as new (message: string) => Error;

NativeError.prototype = Object.create(Error.prototype);


export class ForbiddenError<T extends AnyAbility> extends NativeError {
public readonly ability!: T;
public action!: Normalize<Generics<T>['abilities']>[0];
public subject!: Generics<T>['abilities'][1];
public action!: Normalize<Generics<T>["abilities"]>[0];
public subject!: Generics<T>["abilities"][1];
public field?: string;
public subjectType!: string;
public inverted!: boolean;

static _defaultErrorMessage = getDefaultErrorMessage;

static setDefaultMessage(messageOrFn: string | GetErrorMessage) {
this._defaultErrorMessage = typeof messageOrFn === 'string' ? () => messageOrFn : messageOrFn;
this._defaultErrorMessage =
typeof messageOrFn === "string" ? () => messageOrFn : messageOrFn;
}

static from<U extends AnyAbility>(ability: U): ForbiddenError<U> {
return new this<U>(ability);
}

private constructor(ability: T) {
super('');
super("");
this.ability = ability;

if (typeof Error.captureStackTrace === 'function') {
this.name = 'ForbiddenError';
if (typeof Error.captureStackTrace === "function") {
this.name = "ForbiddenError";
Error.captureStackTrace(this, this.constructor);
}
}
Expand All @@ -45,28 +49,83 @@ export class ForbiddenError<T extends AnyAbility> extends NativeError {
return this;
}

throwUnlessCan(...args: Parameters<T['can']>): void;
throwUnlessCan(action: string, subject?: Subject, field?: string): void {
const error = (this as any).unlessCan(action, subject, field);
if (error) throw error;
}

unlessCan(...args: Parameters<T['can']>): this | undefined;
unlessCan(action: string, subject?: Subject, field?: string): this | undefined {
private handleRule(
action: string,
inverted: boolean,
subject?: Subject,
field?: string,
): this | undefined {
const rule = this.ability.relevantRuleFor(action, subject, field);

if (rule && !rule.inverted) {
if (inverted && (!rule || rule.inverted)) {
return;
} else if (!inverted && rule && !rule.inverted) {
return;
}

this.action = action;
this.subject = subject;
this.subjectType = getSubjectTypeName(this.ability.detectSubjectType(subject));
this.subjectType = getSubjectTypeName(
this.ability.detectSubjectType(subject)
);
this.field = field;
this.inverted = inverted;

const reason = rule ? rule.reason : '';
const reason = rule ? rule.reason : "";
// eslint-disable-next-line no-underscore-dangle
this.message = this.message || reason || (this.constructor as any)._defaultErrorMessage(this);
this.message =
this.message ||
reason ||
(this.constructor as any)._defaultErrorMessage(this, !!inverted);
return this; // eslint-disable-line consistent-return
}

private unless(inverted: boolean, ...args: Parameters<T["can"]> ): this | undefined
private unless(
inverted: boolean,
action: string,
subject?: Subject,
field?: string
): this | undefined {
return this.handleRule(action, inverted, subject, field,);
}

private throwUnless(inverted: boolean, ...args: Parameters<T["can"]> | Parameters<T["cannot"]>): void
private throwUnless(
inverted: boolean,
action: string,
subject?: Subject,
field?: string
): void {
const error = (this as any).unless(inverted, action, subject, field);
if (error) throw error;
}

throwUnlessCan(...args: Parameters<T["can"]>): void;
throwUnlessCan(action: string, subject?: Subject, field?: string): void {
(this as any).throwUnless(false, action, subject, field)
}

unlessCan(...args: Parameters<T["can"]>): this | undefined;
unlessCan(
action: string,
subject?: Subject,
field?: string
): this | undefined {
return (this as any).unless(false, action, subject, field);
}

throwUnlessCannot(...args: Parameters<T["cannot"]>): void;
throwUnlessCannot(action: string, subject?: Subject, field?: string): void {
return (this as any).throwUnless(true, action, subject, field);
}

unlessCannot(...args: Parameters<T["cannot"]>): this | undefined;
unlessCannot(
action: string,
subject?: Subject,
field?: string
): this | undefined {
return (this as any).unless(true, action, subject, field);
}
}
32 changes: 29 additions & 3 deletions packages/casl-react/spec/Can.spec.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,41 @@
import { defineAbility, ForbiddenError } from '@casl/ability'
import { createElement as e } from 'react'
import { defineAbility } from '@casl/ability'
import renderer from 'react-test-renderer'
import { Can } from '../src'

describe('`Can` component', () => {
let ability
let children
let cantChopWoodReason = 'You are not a lumberjack'

beforeEach(() => {
children = spy(() => null)
ability = defineAbility(can => can('read', 'Post'))
ability = defineAbility((can, cannot) => {
can('read', 'Post')
cannot('chop', 'Wood').because(cantChopWoodReason)
})

})

it('passes ability check value and instance as arguments to "children" function', () => {
renderer.create(e(Can, { I: 'read', a: 'Post', ability }, children))

expect(children).to.have.been.called.with.exactly(ability.can('read', 'Post'), ability)
expect(children).to.have.been.called.with.exactly(ability.can('read', 'Post'), ability, undefined)
})

it('passes forbidden reason message to "children" function when not allowed', () => {

renderer.create(e(Can, { I: 'chop', a: 'Wood', ability, passThrough: true }, children))
expect(children).to.have.been.called.with.exactly(ability.can('chop', 'Wood'), ability, ForbiddenError.from(ability).unlessCan('chop', 'Wood')?.message)
})

it('Does not pass forbidden reason message to "children" function when allowed', () => {
renderer.create(e(Can, { not: true, I: 'chop', a: 'Wood', ability, passThrough: true }, children))

expect(children).to.have.been.called.with.exactly(ability.cannot('chop', 'Wood'), ability, undefined)
})


it('has public "allowed" property which returns boolean indicating whether children will be rendered', () => {
const canComponent = renderer.create(e(Can, { I: 'read', a: 'Post', ability }, children))
renderer.create(e(Can, { not: true, I: 'run', a: 'Marathon', ability }, children))
Expand All @@ -26,6 +44,14 @@ describe('`Can` component', () => {
expect(canComponent.getInstance().allowed).to.equal(ability.cannot('run', 'Marathon'))
})

it('has public "forbiddenReason" property which returns the message for ForbiddenError ', () => {
const canComponent = renderer.create(e(Can, { I: 'read', a: 'Post', ability }, children))
renderer.create(e(Can, { not: true, I: 'run', a: 'Marathon', ability }, children))

expect(canComponent.getInstance().forbiddenReason).to.equal(ForbiddenError.from(ability).unlessCan('read', 'Post')?.message)
expect(canComponent.getInstance().forbiddenReason).to.equal(ForbiddenError.from(ability).unlessCannot('run', 'Marathon')?.message)
})

it('unsubscribes from ability updates when unmounted', () => {
const component = renderer.create(e(Can, { I: 'read', a: 'Post', ability }, children))

Expand Down
Loading
Loading