@appsemble/utils 0.24.0 → 0.24.2

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/README.md CHANGED
@@ -1,9 +1,9 @@
1
- # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.24.0/config/assets/logo.svg) Appsemble Utilities
1
+ # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.24.2/config/assets/logo.svg) Appsemble Utilities
2
2
 
3
3
  > Internal utility functions used across multiple Appsemble projects.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@appsemble/utils)](https://www.npmjs.com/package/@appsemble/utils)
6
- [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.24.0/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.24.0)
6
+ [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.24.2/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.24.2)
7
7
  [![Prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://prettier.io)
8
8
 
9
9
  ## Table of Contents
@@ -26,5 +26,5 @@ not guaranteed.
26
26
 
27
27
  ## License
28
28
 
29
- [LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.24.0/LICENSE.md) ©
29
+ [LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.24.2/LICENSE.md) ©
30
30
  [Appsemble](https://appsemble.com)
@@ -68,6 +68,9 @@ This **must** match the name of a page defined for the app.
68
68
  controller: {
69
69
  $ref: '#/components/schemas/ControllerDefinition',
70
70
  },
71
+ users: {
72
+ $ref: '#/components/schemas/UsersDefinition',
73
+ },
71
74
  resources: {
72
75
  type: 'object',
73
76
  description: `Resources define how Appsemble can store data for an app.
@@ -10,9 +10,21 @@ export const Resource = {
10
10
  $clonable: {
11
11
  type: 'boolean',
12
12
  },
13
+ $ephemeral: {
14
+ type: 'boolean',
15
+ },
13
16
  $expires: {
14
- type: 'string',
15
- format: 'date-time',
17
+ anyOf: [
18
+ {
19
+ type: 'string',
20
+ format: 'date-time',
21
+ },
22
+ {
23
+ type: 'string',
24
+ pattern: /^(\d+(y|yr|years))?\s*(\d+months)?\s*(\d+(w|wk|weeks))?\s*(\d+(d|days))?\s*(\d+(h|hr|hours))?\s*(\d+(m|min|minutes))?\s*(\d+(s|sec|seconds))?$/
25
+ .source,
26
+ },
27
+ ],
16
28
  },
17
29
  },
18
30
  };
@@ -62,6 +62,14 @@ Example: 1d 8h 30m
62
62
  pattern: /^(\d+(y|yr|years))?\s*(\d+months)?\s*(\d+(w|wk|weeks))?\s*(\d+(d|days))?\s*(\d+(h|hr|hours))?\s*(\d+(m|min|minutes))?\s*(\d+(s|sec|seconds))?$/
63
63
  .source,
64
64
  },
65
+ clonable: {
66
+ type: 'boolean',
67
+ description: 'Whether the resource should be able to be transferred when cloning the app it belongs to',
68
+ },
69
+ ephemeral: {
70
+ type: 'boolean',
71
+ description: 'Whether the resource should be cleaned up regularly.',
72
+ },
65
73
  schema: {
66
74
  $ref: '#/components/schemas/JSONSchemaRoot',
67
75
  description: 'JSON schema definitions that may be used by the app.',
@@ -23,7 +23,7 @@ Does nothing if the user is already logged in.`,
23
23
  properties: {
24
24
  description: `The custom properties for the user.
25
25
 
26
- Every value will be converted to a string.`,
26
+ Values will be validated based on \`user.properties\`, if defined in the app definition.`,
27
27
  },
28
28
  role: {
29
29
  description: "The role for the created account. Defaults to the default role in the app's security definition.",
@@ -0,0 +1,2 @@
1
+ import { type OpenAPIV3 } from 'openapi-types';
2
+ export declare const UserPropertyDefinition: OpenAPIV3.NonArraySchemaObject;
@@ -0,0 +1,47 @@
1
+ export const UserPropertyDefinition = {
2
+ anyOf: [
3
+ {
4
+ type: 'object',
5
+ description: 'Definition for a user property.',
6
+ required: ['schema'],
7
+ additionalProperties: false,
8
+ properties: {
9
+ schema: {
10
+ anyOf: [
11
+ { $ref: '#/components/schemas/JSONSchemaInteger' },
12
+ { $ref: '#/components/schemas/JSONSchemaArray' },
13
+ ],
14
+ },
15
+ reference: {
16
+ type: 'object',
17
+ additionalProperties: false,
18
+ description: 'The object for the reference to another resource',
19
+ properties: {
20
+ resource: {
21
+ type: 'string',
22
+ description: 'The resource referenced by this user property.',
23
+ },
24
+ },
25
+ },
26
+ },
27
+ },
28
+ {
29
+ type: 'object',
30
+ description: 'Definition for a user property.',
31
+ required: ['schema'],
32
+ additionalProperties: false,
33
+ properties: {
34
+ schema: {
35
+ anyOf: [
36
+ { $ref: '#/components/schemas/JSONSchemaString' },
37
+ { $ref: '#/components/schemas/JSONSchemaNumber' },
38
+ { $ref: '#/components/schemas/JSONSchemaEnum' },
39
+ { $ref: '#/components/schemas/JSONSchemaBoolean' },
40
+ { $ref: '#/components/schemas/JSONSchemaObject' },
41
+ ],
42
+ },
43
+ },
44
+ },
45
+ ],
46
+ };
47
+ //# sourceMappingURL=UserPropertyDefinition.js.map
@@ -26,7 +26,7 @@ Does nothing if the user is already logged in.`,
26
26
  properties: {
27
27
  description: `The custom properties for the user.
28
28
 
29
- Every value will be converted to a string.`,
29
+ Values will be validated based on \`user.properties\`, if defined in the app definition.`,
30
30
  },
31
31
  login: {
32
32
  description: 'Whether to login after registering.',
@@ -26,7 +26,7 @@ Does nothing if the user isn’t logged in.`,
26
26
  properties: {
27
27
  description: `The custom properties for the user.
28
28
 
29
- Every value will be converted to a string.`,
29
+ Values will be validated based on \`user.properties\`, if defined in the app definition.`,
30
30
  },
31
31
  role: {
32
32
  description: "The role for the updated account. Defaults to the default role in the app's security definition.",
@@ -0,0 +1,2 @@
1
+ import { type OpenAPIV3 } from 'openapi-types';
2
+ export declare const UsersDefinition: OpenAPIV3.NonArraySchemaObject;
@@ -0,0 +1,17 @@
1
+ export const UsersDefinition = {
2
+ type: 'object',
3
+ description: 'Definition for user properties.',
4
+ required: [],
5
+ additionalProperties: false,
6
+ properties: {
7
+ properties: {
8
+ type: 'object',
9
+ description: 'The properties object configuring users in the app',
10
+ additionalProperties: {
11
+ description: 'A single user property definition.',
12
+ $ref: '#/components/schemas/UserPropertyDefinition',
13
+ },
14
+ },
15
+ },
16
+ };
17
+ //# sourceMappingURL=UsersDefinition.js.map
@@ -127,3 +127,5 @@ export * from './UserQueryActionDefinition.js';
127
127
  export * from './UserRemoveActionDefinition.js';
128
128
  export * from './LoopPageDefinition.js';
129
129
  export * from './LoopPageActionsDefinition.js';
130
+ export * from './UsersDefinition.js';
131
+ export * from './UserPropertyDefinition.js';
@@ -127,4 +127,6 @@ export * from './UserQueryActionDefinition.js';
127
127
  export * from './UserRemoveActionDefinition.js';
128
128
  export * from './LoopPageDefinition.js';
129
129
  export * from './LoopPageActionsDefinition.js';
130
+ export * from './UsersDefinition.js';
131
+ export * from './UserPropertyDefinition.js';
130
132
  //# sourceMappingURL=index.js.map
package/api/paths/apps.js CHANGED
@@ -1477,5 +1477,16 @@ This will return a 404 if the user has not uploaded one.`,
1477
1477
  security: [{ app: ['teams:read'] }],
1478
1478
  },
1479
1479
  },
1480
+ '/api/apps/{appId}/reseed': {
1481
+ parameters: [{ $ref: '#/components/parameters/appId' }],
1482
+ post: {
1483
+ tags: ['app'],
1484
+ operationId: 'reseedDemoApp',
1485
+ responses: {
1486
+ 200: { description: 'The app has successfully been reseeded.' },
1487
+ },
1488
+ security: [{ studio: ['apps:write'] }],
1489
+ },
1490
+ },
1480
1491
  };
1481
1492
  //# sourceMappingURL=apps.js.map
@@ -44,6 +44,10 @@ export const paths = {
44
44
  pattern: normalized.source,
45
45
  description: 'The given name of the asset. Assets may be referenced by their name or ID in the API.',
46
46
  },
47
+ clonable: {
48
+ type: 'boolean',
49
+ description: 'Whether the asset should be transferable when cloning the app they are in.',
50
+ },
47
51
  },
48
52
  },
49
53
  },
@@ -64,6 +64,10 @@ export const paths = {
64
64
  type: 'boolean',
65
65
  description: 'Include example resources.',
66
66
  },
67
+ assets: {
68
+ type: 'boolean',
69
+ description: 'Include example assets.',
70
+ },
67
71
  visibility: {
68
72
  $ref: '#/components/schemas/App/properties/visibility',
69
73
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/utils",
3
- "version": "0.24.0",
3
+ "version": "0.24.2",
4
4
  "description": "Utility functions used in Appsemble internally",
5
5
  "keywords": [
6
6
  "app",
@@ -37,7 +37,7 @@
37
37
  "test": "vitest"
38
38
  },
39
39
  "dependencies": {
40
- "@appsemble/types": "0.24.0",
40
+ "@appsemble/types": "0.24.2",
41
41
  "axios": "^1.0.0",
42
42
  "cron-parser": "^4.0.0",
43
43
  "date-fns": "^2.0.0",
package/validation.js CHANGED
@@ -34,6 +34,37 @@ function validateJSONSchema(schema, prefix, report) {
34
34
  }
35
35
  }
36
36
  }
37
+ function validateUsersSchema(definition, report) {
38
+ var _a;
39
+ if (!definition.users) {
40
+ return;
41
+ }
42
+ for (const [propertyName, propertyDefinition] of Object.entries(definition.users.properties)) {
43
+ // Handled by schema validation
44
+ if (!(propertyDefinition === null || propertyDefinition === void 0 ? void 0 : propertyDefinition.schema)) {
45
+ continue;
46
+ }
47
+ const { schema } = propertyDefinition;
48
+ const prefix = ['users', 'properties', propertyName, 'schema'];
49
+ validateJSONSchema(schema, prefix, report);
50
+ if (!('type' in schema) && !('enum' in schema)) {
51
+ report(schema, 'must define type or enum', prefix);
52
+ }
53
+ if ('reference' in propertyDefinition) {
54
+ const { resource: resourceName } = propertyDefinition.reference;
55
+ const resourceDefinition = (_a = definition.resources) === null || _a === void 0 ? void 0 : _a[resourceName];
56
+ if (!resourceDefinition) {
57
+ report(resourceName, 'refers to a resource that doesn’t exist', [
58
+ 'users',
59
+ 'properties',
60
+ propertyName,
61
+ 'reference',
62
+ resourceName,
63
+ ]);
64
+ }
65
+ }
66
+ }
67
+ }
37
68
  function validateResourceSchemas(definition, report) {
38
69
  if (!definition.resources) {
39
70
  return;
@@ -447,7 +478,7 @@ function validateActions(definition, report) {
447
478
  const urlRegex = new RegExp(`^${partialNormalized.source}:`);
448
479
  iterApp(definition, {
449
480
  onAction(action, path) {
450
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
481
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
451
482
  if (path[0] === 'cron' && !serverActions.has(action.type)) {
452
483
  report(action.type, 'action type is not supported for cron jobs', [...path, 'type']);
453
484
  return;
@@ -456,17 +487,29 @@ function validateActions(definition, report) {
456
487
  report(action.type, 'refers to a user action but the app doesn’t have a security definition', [...path, 'type']);
457
488
  return;
458
489
  }
490
+ if (['user.register', 'user.create', 'user.update'].includes(action.type) &&
491
+ Object.values(action.properties)[0] &&
492
+ ((_a = definition.users) === null || _a === void 0 ? void 0 : _a.properties)) {
493
+ for (const propertyName of Object.keys(Object.values(action.properties)[0])) {
494
+ if (!((_b = definition.users) === null || _b === void 0 ? void 0 : _b.properties[propertyName])) {
495
+ report(action.type, 'contains a property that doesn’t exist in users.properties', [
496
+ ...path,
497
+ 'properties',
498
+ ]);
499
+ }
500
+ }
501
+ }
459
502
  if (action.type.startsWith('resource.')) {
460
503
  // All of the actions starting with `resource.` contain a property called `resource`.
461
504
  const { resource: resourceName, view } = action;
462
- const resource = (_a = definition.resources) === null || _a === void 0 ? void 0 : _a[resourceName];
505
+ const resource = (_c = definition.resources) === null || _c === void 0 ? void 0 : _c[resourceName];
463
506
  if (!resource) {
464
507
  report(action.type, 'refers to a resource that doesn’t exist', [...path, 'resource']);
465
508
  return;
466
509
  }
467
510
  if (!action.type.startsWith('resource.subscription.')) {
468
511
  const type = action.type.split('.')[1];
469
- const roles = (_c = (_b = resource === null || resource === void 0 ? void 0 : resource[type]) === null || _b === void 0 ? void 0 : _b.roles) !== null && _c !== void 0 ? _c : resource === null || resource === void 0 ? void 0 : resource.roles;
512
+ const roles = (_e = (_d = resource === null || resource === void 0 ? void 0 : resource[type]) === null || _d === void 0 ? void 0 : _d.roles) !== null && _e !== void 0 ? _e : resource === null || resource === void 0 ? void 0 : resource.roles;
470
513
  if (!roles) {
471
514
  report(action.type, 'refers to a resource action that is currently set to private', [
472
515
  ...path,
@@ -479,11 +522,11 @@ function validateActions(definition, report) {
479
522
  return;
480
523
  }
481
524
  if ((type === 'get' || type === 'query') && view) {
482
- if (!((_d = resource.views) === null || _d === void 0 ? void 0 : _d[view])) {
525
+ if (!((_f = resource.views) === null || _f === void 0 ? void 0 : _f[view])) {
483
526
  report(action.type, 'refers to a view that doesn’t exist', [...path, 'view']);
484
527
  return;
485
528
  }
486
- const viewRoles = (_e = resource === null || resource === void 0 ? void 0 : resource.views) === null || _e === void 0 ? void 0 : _e[view].roles;
529
+ const viewRoles = (_g = resource === null || resource === void 0 ? void 0 : resource.views) === null || _g === void 0 ? void 0 : _g[view].roles;
487
530
  if (!(viewRoles === null || viewRoles === void 0 ? void 0 : viewRoles.length)) {
488
531
  report(action.type, 'refers to a resource view that is currently set to private', [
489
532
  ...path,
@@ -499,19 +542,19 @@ function validateActions(definition, report) {
499
542
  }
500
543
  }
501
544
  if (action.type.startsWith('flow.')) {
502
- const page = (_f = definition.pages) === null || _f === void 0 ? void 0 : _f[Number(path[1])];
545
+ const page = (_h = definition.pages) === null || _h === void 0 ? void 0 : _h[Number(path[1])];
503
546
  if (page.type !== 'flow' && page.type !== 'loop') {
504
547
  report(action.type, 'flow actions can only be used on pages with the type ‘flow’ or ‘loop’', [...path, 'type']);
505
548
  return;
506
549
  }
507
- if (action.type === 'flow.cancel' && !((_g = page.actions) === null || _g === void 0 ? void 0 : _g.onFlowCancel)) {
550
+ if (action.type === 'flow.cancel' && !((_j = page.actions) === null || _j === void 0 ? void 0 : _j.onFlowCancel)) {
508
551
  report(action.type, 'was defined but ‘onFlowCancel’ page action wasn’t defined', [
509
552
  ...path,
510
553
  'type',
511
554
  ]);
512
555
  return;
513
556
  }
514
- if (action.type === 'flow.finish' && !((_h = page.actions) === null || _h === void 0 ? void 0 : _h.onFlowFinish)) {
557
+ if (action.type === 'flow.finish' && !((_k = page.actions) === null || _k === void 0 ? void 0 : _k.onFlowFinish)) {
515
558
  report(action.type, 'was defined but ‘onFlowFinish’ page action wasn’t defined', [
516
559
  ...path,
517
560
  'type',
@@ -525,7 +568,7 @@ function validateActions(definition, report) {
525
568
  if (page.type === 'flow' &&
526
569
  action.type === 'flow.next' &&
527
570
  Number(path[3]) === page.steps.length - 1 &&
528
- !((_j = page.actions) === null || _j === void 0 ? void 0 : _j.onFlowFinish)) {
571
+ !((_l = page.actions) === null || _l === void 0 ? void 0 : _l.onFlowFinish)) {
529
572
  report(action.type, 'was defined on the last step but ‘onFlowFinish’ page action wasn’t defined', [...path, 'type']);
530
573
  return;
531
574
  }
@@ -756,6 +799,7 @@ export async function validateAppDefinition(definition, getBlockVersions, contro
756
799
  validateHooks(definition, report);
757
800
  validateLanguage(definition, report);
758
801
  validateResourceReferences(definition, report);
802
+ validateUsersSchema(definition, report);
759
803
  validateResourceSchemas(definition, report);
760
804
  validateSecurity(definition, report);
761
805
  validateBlocks(definition, blockVersionMap, report);
@@ -935,6 +935,45 @@ describe('validateAppDefinition', () => {
935
935
  ]),
936
936
  ]);
937
937
  });
938
+ it('should validate user properties for type or enum', async () => {
939
+ const app = { ...createTestApp(), users: { properties: { foo: { schema: {} } } } };
940
+ const result = await validateAppDefinition(app, () => []);
941
+ expect(result.valid).toBe(false);
942
+ expect(result.errors).toStrictEqual([
943
+ new ValidationError('must define type or enum', {}, undefined, [
944
+ 'users',
945
+ 'properties',
946
+ 'foo',
947
+ 'schema',
948
+ ]),
949
+ ]);
950
+ });
951
+ it('should validate user properties for resource references', async () => {
952
+ const app = {
953
+ ...createTestApp(),
954
+ users: {
955
+ properties: {
956
+ foo: {
957
+ schema: { type: 'integer' },
958
+ reference: {
959
+ resource: 'tasks',
960
+ },
961
+ },
962
+ },
963
+ },
964
+ };
965
+ const result = await validateAppDefinition(app, () => []);
966
+ expect(result.valid).toBe(false);
967
+ expect(result.errors).toStrictEqual([
968
+ new ValidationError('refers to a resource that doesn’t exist', 'tasks', undefined, [
969
+ 'users',
970
+ 'properties',
971
+ 'foo',
972
+ 'reference',
973
+ 'tasks',
974
+ ]),
975
+ ]);
976
+ });
938
977
  it('should validate resources use schemas define a type', async () => {
939
978
  const app = createTestApp();
940
979
  app.resources.person.schema = { properties: {} };
@@ -1689,6 +1728,147 @@ describe('validateAppDefinition', () => {
1689
1728
  new ValidationError('was defined but ‘onFlowCancel’ page action wasn’t defined', 'flow.cancel', undefined, ['pages', 3, 'steps', 1, 'blocks', 0, 'actions', 'onWhatever', 'type']),
1690
1729
  ]);
1691
1730
  });
1731
+ it('should report an error if a user register action on a block adds unsupported user properties', async () => {
1732
+ const app = {
1733
+ ...createTestApp(),
1734
+ users: {
1735
+ properties: {
1736
+ foo: {
1737
+ schema: {
1738
+ type: 'string',
1739
+ },
1740
+ },
1741
+ },
1742
+ },
1743
+ };
1744
+ app.pages[0].blocks.push({
1745
+ type: 'test',
1746
+ version: '1.2.3',
1747
+ actions: {
1748
+ onWhatever: {
1749
+ type: 'user.register',
1750
+ displayName: 'name',
1751
+ email: 'email@example.com',
1752
+ password: 'password',
1753
+ properties: {
1754
+ 'object.from': {
1755
+ bar: 'baz',
1756
+ },
1757
+ },
1758
+ },
1759
+ },
1760
+ });
1761
+ const result = await validateAppDefinition(app, () => [
1762
+ {
1763
+ name: '@appsemble/test',
1764
+ version: '1.2.3',
1765
+ files: [],
1766
+ languages: [],
1767
+ actions: {
1768
+ onWhatever: {},
1769
+ },
1770
+ },
1771
+ ]);
1772
+ expect(result.valid).toBe(false);
1773
+ expect(result.errors).toStrictEqual([
1774
+ new ValidationError('contains a property that doesn’t exist in users.properties', 'user.register', undefined, ['pages', 0, 'blocks', 0, 'actions', 'onWhatever', 'properties']),
1775
+ ]);
1776
+ });
1777
+ it('should report an error if a user create action on a block adds unsupported user properties', async () => {
1778
+ const app = {
1779
+ ...createTestApp(),
1780
+ users: {
1781
+ properties: {
1782
+ foo: {
1783
+ schema: {
1784
+ type: 'string',
1785
+ },
1786
+ },
1787
+ },
1788
+ },
1789
+ };
1790
+ app.pages[0].blocks.push({
1791
+ type: 'test',
1792
+ version: '1.2.3',
1793
+ actions: {
1794
+ onWhatever: {
1795
+ type: 'user.create',
1796
+ name: 'name',
1797
+ email: 'email@example.com',
1798
+ password: 'password',
1799
+ role: 'role',
1800
+ properties: {
1801
+ 'object.from': {
1802
+ bar: 'baz',
1803
+ },
1804
+ },
1805
+ },
1806
+ },
1807
+ });
1808
+ const result = await validateAppDefinition(app, () => [
1809
+ {
1810
+ name: '@appsemble/test',
1811
+ version: '1.2.3',
1812
+ files: [],
1813
+ languages: [],
1814
+ actions: {
1815
+ onWhatever: {},
1816
+ },
1817
+ },
1818
+ ]);
1819
+ expect(result.valid).toBe(false);
1820
+ expect(result.errors).toStrictEqual([
1821
+ new ValidationError('contains a property that doesn’t exist in users.properties', 'user.create', undefined, ['pages', 0, 'blocks', 0, 'actions', 'onWhatever', 'properties']),
1822
+ ]);
1823
+ });
1824
+ it('should report an error if a user update action on a block adds unsupported user properties', async () => {
1825
+ const app = {
1826
+ ...createTestApp(),
1827
+ users: {
1828
+ properties: {
1829
+ foo: {
1830
+ schema: {
1831
+ type: 'string',
1832
+ },
1833
+ },
1834
+ },
1835
+ },
1836
+ };
1837
+ app.pages[0].blocks.push({
1838
+ type: 'test',
1839
+ version: '1.2.3',
1840
+ actions: {
1841
+ onWhatever: {
1842
+ type: 'user.update',
1843
+ name: 'name',
1844
+ currentEmail: 'email@example.com',
1845
+ newEmail: 'new-email@example.com',
1846
+ password: 'password',
1847
+ role: 'role',
1848
+ properties: {
1849
+ 'object.from': {
1850
+ bar: 'baz',
1851
+ },
1852
+ },
1853
+ },
1854
+ },
1855
+ });
1856
+ const result = await validateAppDefinition(app, () => [
1857
+ {
1858
+ name: '@appsemble/test',
1859
+ version: '1.2.3',
1860
+ files: [],
1861
+ languages: [],
1862
+ actions: {
1863
+ onWhatever: {},
1864
+ },
1865
+ },
1866
+ ]);
1867
+ expect(result.valid).toBe(false);
1868
+ expect(result.errors).toStrictEqual([
1869
+ new ValidationError('contains a property that doesn’t exist in users.properties', 'user.update', undefined, ['pages', 0, 'blocks', 0, 'actions', 'onWhatever', 'properties']),
1870
+ ]);
1871
+ });
1692
1872
  it('should report an error if a resource action on a block refers to a non-existent resource', async () => {
1693
1873
  const app = createTestApp();
1694
1874
  app.pages[0].blocks.push({