From 4324322ab4c93db1df7973c7f3e3efd856164e5b Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 12 Apr 2023 18:23:12 +0200 Subject: [PATCH] scaffolder-backend: add tests for action rules Signed-off-by: Vincenzo Scamporlino --- .../src/service/rules.test.ts | 365 +++++++++++++++++- .../scaffolder-backend/src/service/rules.ts | 60 ++- 2 files changed, 404 insertions(+), 21 deletions(-) diff --git a/plugins/scaffolder-backend/src/service/rules.test.ts b/plugins/scaffolder-backend/src/service/rules.test.ts index 7d8e0eb445..e6032dde15 100644 --- a/plugins/scaffolder-backend/src/service/rules.test.ts +++ b/plugins/scaffolder-backend/src/service/rules.test.ts @@ -14,7 +14,15 @@ * limitations under the License. */ -import { hasTag } from './rules'; +import { JsonObject, JsonPrimitive } from '@backstage/types'; +import { + hasActionId, + hasBooleanProperty, + hasNumberProperty, + hasProperty, + hasStringProperty, + hasTag, +} from './rules'; describe('hasTag', () => { describe('apply', () => { @@ -79,3 +87,358 @@ describe('hasTag', () => { }); }); }); + +describe('hasActionId', () => { + describe('apply', () => { + it('returns false when actionId is not matched', () => { + expect( + hasActionId.apply( + { + action: 'action', + input: {}, + }, + { + actionId: 'not-matched', + }, + ), + ).toEqual(false); + }); + + it('returns true when actionId is matched', () => { + expect( + hasActionId.apply( + { + action: 'action', + input: {}, + }, + { + actionId: 'action', + }, + ), + ).toEqual(true); + }); + }); +}); + +const input: JsonObject = { + propwithstring: '1', + propwithnumber: 2, + propwithobject: {}, + propwithnull: null, + propwithfalse: false, + propwithtrue: true, + propwitharray: ['item', 0, true, false], + nested: { propwithstring: '1', nested: { propwithnumber: 1 } }, +}; + +describe('hasProperty', () => { + describe('apply', () => { + it.each([ + 'foo', + 'bar', + 'prop.prop', + 'nested.nonexisting', + '', + 'propwitharray.100', + ])(`returns false when a property doesn't exist in the input`, key => { + expect(hasProperty.apply({ action: 'action', input }, { key })).toEqual( + false, + ); + }); + + it.each([ + 'propwithstring', + 'propwithnumber', + 'propwithobject', + 'propwithnull', + 'propwithfalse', + 'propwithtrue', + 'propwitharray', + 'propwitharray.1', + 'nested.propwithstring', + 'nested.nested', + 'nested.nested.propwithnumber', + ])(`returns true when a property exists, property=%s`, key => { + expect(hasProperty.apply({ action: 'action', input }, { key })).toEqual( + true, + ); + }); + + it.each([ + ['propwithstring', 1], + ['propwithnumber', '2'], + ['propwithnumber', true], + ['propwithobject', [{}]], + ['propwithnull', false], + ['propwithfalse', true], + ['propwithtrue', null], + ['propwitharray', 'nonexistingitem'], + ['propwitharray.0', 'nonmatchingitem'], + ['nested.propwithstring', 'x'], + ['nested.nested', '1'], + ['nested.nested.propwithnumber', 'ops'], + ])( + `returns false when a property exists but the value doesn't match, key=%s value=%o`, + (key, value) => { + expect( + hasProperty.apply( + { action: 'action', input }, + { key, value: value as JsonPrimitive }, + ), + ).toEqual(false); + }, + ); + + it.each([ + ['propwithstring', '1'], + ['propwithnumber', 2], + ['propwithnull', null], + ['propwithfalse', false], + ['propwithtrue', true], + ['propwitharray.0', 'item'], + ['nested.propwithstring', '1'], + ['nested.nested.propwithnumber', 1], + ])( + `returns true when a property exists and the value matches, key=%s value=%o`, + (key, value) => { + expect( + hasProperty.apply({ action: 'action', input }, { key, value }), + ).toEqual(true); + }, + ); + }); +}); + +describe('hasBooleanProperty', () => { + describe('apply', () => { + it.each(['foo', 'bar', 'prop.prop', 'nested.nonexisting', ''])( + `returns false when a property doesn't exist in the input`, + key => { + expect( + hasBooleanProperty.apply({ action: 'action', input }, { key }), + ).toEqual(false); + }, + ); + + it.each([ + 'propwithstring', + 'propwithnumber', + 'propwithobject', + 'propwithnull', + 'propwitharray', + 'propwitharray.0', + 'nested.propwithstring', + 'nested.nested', + 'nested.nested.propwithnumber', + ])( + `returns false when a property exists and is not a boolean, property=%s`, + key => { + expect( + hasBooleanProperty.apply({ action: 'action', input }, { key }), + ).toEqual(false); + }, + ); + + it.each([ + 'propwithfalse', + 'propwithtrue', + 'propwitharray.2', + 'propwitharray.3', + ])(`returns true when a property exists, property=%s`, key => { + expect( + hasBooleanProperty.apply({ action: 'action', input }, { key }), + ).toEqual(true); + }); + + it.each([ + ['propwithstring', true], + ['propwithnumber', true], + ['propwithnumber', true], + ['propwithobject', true], + ['propwithnull', true], + ['propwithfalse', true], + ['propwithtrue', false], + ['propwitharray', true], + ['propwitharray.2', false], + ['propwitharray.3', true], + ['nested.propwithstring', true], + ['nested.nested', true], + ['nested.nested.propwithnumber', true], + ])( + `returns false when a property exists but the value doesn't match, key=%s value=%o`, + (key, value) => { + expect( + hasBooleanProperty.apply({ action: 'action', input }, { key, value }), + ).toEqual(false); + }, + ); + + it.each([ + ['propwithfalse', false], + ['propwithtrue', true], + ['propwitharray.2', true], + ['propwitharray.3', false], + ])( + `returns true when a property exists and the value matches, key=%s value=%o`, + (key, value) => { + expect( + hasBooleanProperty.apply({ action: 'action', input }, { key, value }), + ).toEqual(true); + }, + ); + }); +}); + +describe('hasNumberProperty', () => { + describe('apply', () => { + it.each(['foo', 'bar', 'prop.prop', 'nested.nonexisting', ''])( + `returns false when a property doesn't exist in the input`, + key => { + expect( + hasNumberProperty.apply({ action: 'action', input }, { key }), + ).toEqual(false); + }, + ); + + it.each([ + 'propwithstring', + 'propwithobject', + 'propwithnull', + 'propwithfalse', + 'propwithtrue', + 'propwitharray', + 'propwitharray.0', + 'nested.propwithstring', + 'nested.nested', + ])( + `returns false when a property exists and is not a number, property=%s`, + key => { + expect( + hasNumberProperty.apply({ action: 'action', input }, { key }), + ).toEqual(false); + }, + ); + + it.each([ + 'propwithnumber', + 'nested.nested.propwithnumber', + 'propwitharray.1', + ])(`returns true when a property exists, property=%s`, key => { + expect( + hasNumberProperty.apply({ action: 'action', input }, { key }), + ).toEqual(true); + }); + + it.each([ + ['propwithstring', 1], + ['propwithnumber', 1000], + ['propwithnumber', 101], + ['propwithobject', 1], + ['propwithnull', 1], + ['propwithfalse', 1], + ['propwithtrue', 1], + ['propwitharray', 1], + ['propwitharray.2', 1], + ['propwitharray.3', 1], + ['nested.propwithstring', 1], + ['nested.nested', 1], + ['nested.nested.propwithnumber', 100], + ])( + `returns false when a property exists but the value doesn't match, key=%s value=%o`, + (key, value) => { + expect( + hasNumberProperty.apply({ action: 'action', input }, { key, value }), + ).toEqual(false); + }, + ); + + it.each([ + ['propwithnumber', 2], + ['nested.nested.propwithnumber', 1], + ['propwitharray.1', 0], + ])( + `returns true when a property exists and the value matches, key=%s value=%o`, + (key, value) => { + expect( + hasNumberProperty.apply({ action: 'action', input }, { key, value }), + ).toEqual(true); + }, + ); + }); +}); + +describe('hasStringProperty', () => { + describe('apply', () => { + it.each(['foo', 'bar', 'prop.prop', 'nested.nonexisting', ''])( + `returns false when a property doesn't exist in the input`, + key => { + expect( + hasStringProperty.apply({ action: 'action', input }, { key }), + ).toEqual(false); + }, + ); + + it.each([ + 'propwithnumber', + 'propwithobject', + 'propwithnull', + 'propwithfalse', + 'propwithtrue', + 'propwitharray', + 'propwitharray.1', + 'nested.nested.propwithnumber', + 'nested.nested', + ])( + `returns false when a property exists and is not a string, property=%s`, + key => { + expect( + hasStringProperty.apply({ action: 'action', input }, { key }), + ).toEqual(false); + }, + ); + + it.each(['propwithstring', 'nested.propwithstring', 'propwitharray.0'])( + `returns true when a property exists, property=%s`, + key => { + expect( + hasStringProperty.apply({ action: 'action', input }, { key }), + ).toEqual(true); + }, + ); + + it.each([ + ['propwithstring', 'nonmatchingstring'], + ['propwithnumber', 's'], + ['propwithnumber', 's'], + ['propwithobject', 's'], + ['propwithnull', 's'], + ['propwithfalse', 's'], + ['propwithtrue', 's'], + ['propwitharray', 's'], + ['propwitharray.2', 's'], + ['propwitharray.3', 's'], + ['nested.nested', 's'], + ['nested.nested.propwithnumber', 's'], + ])( + `returns false when a property exists but the value doesn't match, key=%s value=%o`, + (key, value) => { + expect( + hasStringProperty.apply({ action: 'action', input }, { key, value }), + ).toEqual(false); + }, + ); + + it.each([ + ['propwithstring', '1'], + ['nested.propwithstring', '1'], + ['propwitharray.0', 'item'], + ])( + `returns true when a property exists and the value matches, key=%s value=%o`, + (key, value) => { + expect( + hasStringProperty.apply({ action: 'action', input }, { key, value }), + ).toEqual(true); + }, + ); + }); +}); diff --git a/plugins/scaffolder-backend/src/service/rules.ts b/plugins/scaffolder-backend/src/service/rules.ts index c93681a0fb..19b34b9f7c 100644 --- a/plugins/scaffolder-backend/src/service/rules.ts +++ b/plugins/scaffolder-backend/src/service/rules.ts @@ -26,8 +26,8 @@ import { } from '@backstage/plugin-scaffolder-common'; import { z } from 'zod'; -import { JsonObject, JsonPrimitive, JsonValue } from '@backstage/types'; -import { String, get } from 'lodash'; +import { JsonObject, JsonPrimitive } from '@backstage/types'; +import { get } from 'lodash'; export const createTemplatePermissionRule = makeCreatePermissionRule< TemplateEntityStepV1beta3 | TemplateParametersV1beta3, @@ -70,16 +70,36 @@ export const hasActionId = createActionPermissionRule({ toQuery: () => ({}), }); -export const hasBooleanProperty = buildHasProperty(z.boolean()); -export const hasNullProperty = buildHasProperty(z.null()); -export const hasNumberProperty = buildHasProperty(z.number()); -export const hasStringProperty = buildHasProperty(z.string()); +export const hasProperty = buildHasProperty({ + name: 'HAS_PROPERTY', + valueSchema: z.union([z.string(), z.number(), z.boolean(), z.null()]), + validateProperty: false, +}); -function buildHasProperty>( - valueSchema: Schema, -) { +export const hasBooleanProperty = buildHasProperty({ + name: 'HAS_BOOLEAN_PROPERTY', + valueSchema: z.boolean(), +}); +export const hasNumberProperty = buildHasProperty({ + name: 'HAS_NUMBER_PROPERTY', + valueSchema: z.number(), +}); +export const hasStringProperty = buildHasProperty({ + name: 'HAS_STRING_PROPERTY', + valueSchema: z.string(), +}); + +function buildHasProperty>({ + name, + valueSchema, + validateProperty = true, +}: { + name: string; + valueSchema: Schema; + validateProperty?: boolean; +}) { return createActionPermissionRule({ - name: `HAS_STRING_PROPERTY`, + name, description: `Allow actions with the specified property`, resourceType: RESOURCE_TYPE_SCAFFOLDER_ACTION, paramsSchema: z.object({ @@ -87,20 +107,21 @@ function buildHasProperty>( .string() .describe(`Property within the action parameters to match on`), value: valueSchema.describe(`Value of the given property to match on`), - }) as unknown as z.ZodType<{ key: string; value: z.infer }>, + }) as unknown as z.ZodType<{ key: string; value?: z.infer }>, apply: (resource, { key, value }) => { const foundValue = get(resource.input, key); - if (Array.isArray(foundValue)) { - if (value !== undefined) { - return foundValue.includes(value); + if (validateProperty && !valueSchema.safeParse(foundValue).success) { + return false; + } + if (value !== undefined) { + if (valueSchema.safeParse(value).success) { + return value === foundValue; } - return foundValue.length > 0; + return false; } - if (value !== undefined && z.string().safeParse(value).success) { - return value === foundValue; - } - return !!foundValue; + + return foundValue !== undefined; }, toQuery: () => ({}), }); @@ -110,7 +131,6 @@ export const scaffolderTemplateRules = { hasTag }; export const scaffolderActionRules = { hasActionId, hasBooleanProperty, - hasNullProperty, hasNumberProperty, hasStringProperty, };