fix(appium): disallow unsupported schemas
Some theoretical schemas are not in any sort of shape Appium can use, so we now catch them as soon as possible (at the registration phase).
adds  type guard
adds
MODIFY
packages/appium/lib/schema/schema.js
packages/appium/lib/schema/schema.js
@@ -153,9 +153,7 @@ class AppiumSchema {
const ajv = this._ajv;
// Ajv will _mutate_ the schema, so we need to clone it.
- const baseSchema = _.cloneDeep(
- /** @type {StrictSchemaObject} */ (appiumConfigSchema),
- );
+ const baseSchema = _.cloneDeep(appiumConfigSchema);
/**
*
@@ -280,12 +278,14 @@ class AppiumSchema {
* @returns {void}
*/
registerSchema (extType, extName, schema) {
- if (!(extType && extName && !_.isEmpty(schema))) {
+ if (!(extType && extName) || _.isUndefined(schema)) {
throw new TypeError(
- 'Expected nonempty extension type, extension name and schema parameters',
+ 'Expected extension type, extension name, and a defined schema',
);
}
-
+ if (!AppiumSchema.isSupportedSchemaType(schema)) {
+ throw new SchemaUnsupportedSchemaError(schema, extType, extName);
+ }
const normalizedExtName = _.kebabCase(extName);
if (this.hasRegisteredSchema(extType, normalizedExtName)) {
if (this._registeredSchemas[extType].get(normalizedExtName) === schema) {
@@ -323,12 +323,16 @@ class AppiumSchema {
/**
* Returns a `Record` of argument "dest" strings to default values.
*
- * The "dest" string is the property name in object returned by `argparse.ArgumentParser['parse_args']`.
- * @template {boolean|undefined} T
- * @param {T} [flatten=true] - If `true`, flattens the returned object using "keypath"-style keys of the format `<extType>.<extName>.<argName>`. Otherwise, returns a nested object using `extType` and `extName` as properties. Base arguments (server arguments) are always at the top level.
- * @returns {DefaultValues<T>}
+ * The "dest" string is the property name in object returned by
+ * `argparse.ArgumentParser['parse_args']`.
+ * @template {boolean|undefined} Flattened
+ * @param {Flattened} [flatten=true] - If `true`, flattens the returned object
+ * using "keypath"-style keys of the format `<extType>.<extName>.<argName>`.
+ * Otherwise, returns a nested object using `extType` and `extName` as
+ * properties. Base arguments (server arguments) are always at the top level.
+ * @returns {DefaultValues<Flattened>}
*/
- getDefaults (flatten = /** @type {T} */ (true)) {
+ getDefaults (flatten = /** @type {Flattened} */ (true)) {
if (!this.isFinalized()) {
throw new SchemaFinalizationError();
}
@@ -336,9 +340,9 @@ class AppiumSchema {
/**
* @private
* @callback DefaultReducer
- * @param {DefaultValues<T>} defaults
+ * @param {DefaultValues<Flattened>} defaults
* @param {ArgSpec} argSpec
- * @returns {DefaultValues<T>}
+ * @returns {DefaultValues<Flattened>}
*/
/** @type {DefaultReducer} */
const reducer = flatten
@@ -355,13 +359,14 @@ class AppiumSchema {
return defaults;
};
- /** @type {DefaultValues<T>} */
+ /** @type {DefaultValues<Flattened>} */
const retval = {};
return [...this._argSpecs.values()].reduce(reducer, retval);
}
/**
- * Returns a flattened Record of defaults for a specific extension. Keys will be of format `<argName>`.
+ * Returns a flattened Record of defaults for a specific extension. Keys will
+ * be of format `<argName>`.
* @param {ExtensionType} extType - Extension type
* @param {string} extName - Extension name
* @returns {Record<string,ArgSpecDefaultValue>}
@@ -459,7 +464,7 @@ class AppiumSchema {
* Retrieves the schema itself
* @public
* @param {string} [ref] - Schema ID
- * @throws If the schema has not yet been _finalized_
+ * @throws If the schema has not yet been finalized
* @returns {SchemaObject}
*/
getSchema (ref = APPIUM_CONFIG_SCHEMA_ID) {
@@ -493,9 +498,7 @@ class AppiumSchema {
* @returns {import('ajv').ErrorObject[]} Array of errors, if any.
*/
validate (value, ref = APPIUM_CONFIG_SCHEMA_ID) {
- const validator = /** @type {import('ajv').ValidateFunction} */ (
- this._getValidator(ref)
- );
+ const validator = this._getValidator(ref);
return !validator(value) && _.isArray(validator.errors)
? [...validator.errors]
: [];
@@ -509,6 +512,15 @@ class AppiumSchema {
static isAllowedSchemaFileExtension (filename) {
return ALLOWED_SCHEMA_EXTENSIONS.has(path.extname(filename));
}
+
+ /**
+ * Returns `true` if `schema` is a plain object with a non-true `$async` property.
+ * @param {any} schema - Schema to check
+ * @returns {schema is SchemaObject}
+ */
+ static isSupportedSchemaType (schema) {
+ return _.isPlainObject(schema) && schema.$async !== true;
+ }
}
/**
@@ -577,6 +589,54 @@ export class SchemaUnknownSchemaError extends ReferenceError {
}
}
+/**
+ * Thrown when a schema is provided, but it's of an unsupported type.
+ *
+ * "Valid" schemas which are unsupported include boolean schemas and async schemas
+ * (having a `true` `$async` property).
+ */
+export class SchemaUnsupportedSchemaError extends TypeError {
+ /**
+ * @type {Readonly<string>}
+ */
+ code = 'APPIUMERR_SCHEMA_UNSUPPORTED_SCHEMA';
+
+ /**
+ * @type {Readonly<{schema: any, extType: ExtensionType, extName: string}>}
+ */
+ data;
+
+ /**
+ * @param {any} schema
+ * @param {ExtensionType} extType
+ * @param {string} extName
+ */
+ constructor (schema, extType, extName) {
+ // https://github.com/Microsoft/TypeScript/issues/8277
+ super(
+ (() => {
+ let msg = `Unsupported schema from ${extType} "${extName}":`;
+ if (_.isBoolean(schema)) {
+ return `${msg} schema cannot be a boolean`;
+ }
+ if (_.isPlainObject(schema)) {
+ if (schema.$async) {
+ return `${msg} schema cannot be an async schema`;
+ }
+ /* istanbul ignore next */
+ throw new TypeError(
+ `schema IS supported; this error should not be thrown (this is a bug). value of schema: ${JSON.stringify(
+ schema,
+ )}`,
+ );
+ }
+ return `${msg} schema must be a plain object without a true "$async" property`;
+ })(),
+ );
+ this.data = {schema, extType, extName};
+ }
+}
+
const appiumSchema = AppiumSchema.create();
export const {
@@ -595,7 +655,8 @@ export const {
export const {isAllowedSchemaFileExtension} = AppiumSchema;
/**
- * @typedef {import('ajv').SchemaObject} SchemaObject
+ * Appium only supports schemas that are plain objects; not arrays.
+ * @typedef {import('ajv').SchemaObject & {[key: number]: never}} SchemaObject
*/
/**

MODIFY
packages/appium/test/schema/schema-specs.js
packages/appium/test/schema/schema-specs.js
import _ from 'lodash';
import sinon from 'sinon';
+import { DRIVER_TYPE, PLUGIN_TYPE } from '../../lib/extension-config';
import appiumConfigSchema from '../../lib/schema/appium-config-schema';
+import { APPIUM_CONFIG_SCHEMA_ID } from '../../lib/schema/arg-spec';
import defaultArgsFixture from '../fixtures/default-args';
import DRIVER_SCHEMA_FIXTURE from '../fixtures/driver.schema';
import flattenedSchemaFixture from '../fixtures/flattened-schema';
import { rewiremock } from '../helpers';
-import {APPIUM_CONFIG_SCHEMA_ID} from '../../lib/schema/arg-spec';
const {expect} = chai;
@@ -27,6 +28,11 @@ describe('schema', function () {
*/
let SchemaUnknownSchemaError;
+ /**
+ * @type {typeof import('../../lib/schema/schema').SchemaUnsupportedSchemaError}
+ */
+ let SchemaUnsupportedSchemaError;
+
let mocks;
beforeEach(function () {
@@ -34,13 +40,13 @@ describe('schema', function () {
mocks = {
'resolve-from': sandbox.stub(),
-
'@sidvind/better-ajv-errors': sandbox.stub(),
};
schema = rewiremock.proxy(() => require('../../lib/schema/schema'), mocks);
SchemaFinalizationError = schema.SchemaFinalizationError;
SchemaUnknownSchemaError = schema.SchemaUnknownSchemaError;
+ SchemaUnsupportedSchemaError = schema.SchemaUnsupportedSchemaError;
schema.resetSchema();
});
@@ -52,57 +58,65 @@ describe('schema', function () {
describe('error conditions', function () {
describe('when provided no parameters', function () {
it('should throw a TypeError', function () {
- // @ts-ignore
- expect(() => schema.registerSchema()).to.throw(
- TypeError,
- 'Expected nonempty extension type, extension name and schema parameters',
- );
+ expect(() => {
+ // @ts-expect-error
+ schema.registerSchema();
+ }).to.throw(TypeError, /expected extension type/i);
});
});
describe('when provided `type` and `name`, but not `schema`', function () {
it('should throw a TypeError', function () {
expect(() =>
- // @ts-ignore
- schema.registerSchema('driver', 'whoopeee'),
- ).to.throw(
- TypeError,
- 'Expected nonempty extension type, extension name and schema parameters',
- );
+ // @ts-expect-error
+ schema.registerSchema(DRIVER_TYPE, 'whoopeee'),
+ ).to.throw(TypeError, /expected extension type/i);
});
});
- describe('when provided `type`, a nonempty `name`, but an empty `schema`', function () {
+ describe('when provided `type` and nonempty `schema`, but no `name`', function () {
it('should throw a TypeError', function () {
expect(() =>
- schema.registerSchema('driver', 'whoopeee', {}),
- ).to.throw(
- TypeError,
- 'Expected nonempty extension type, extension name and schema parameters',
+ // @ts-expect-error
+ schema.registerSchema(DRIVER_TYPE, undefined, {
+ title: 'whoopeee',
+ }),
+ ).to.throw(TypeError, /expected extension type/i);
+ });
+ });
+
+ describe('when the schema is of an unsupported type', function () {
+ describe('when schema is a object but not a plain object', function () {
+ it('should throw', function () {
+ expect(() => {
+ // @ts-expect-error
+ schema.registerSchema(DRIVER_TYPE, 'whoopeee', [45]);
+ }).to.throw(
+ SchemaUnsupportedSchemaError,
+ /must be a plain object/i,
);
});
});
- describe('when provided `type` and nonempty `schema`, but no `name`', function () {
- it('should throw a TypeError', function () {
- expect(() =>
- // @ts-ignore
- schema.registerSchema('driver', undefined, {
- title: 'whoopeee',
- }),
- ).to.throw(
- TypeError,
- 'Expected nonempty extension type, extension name and schema parameters',
+ describe('when the schema is async', function () {
+ it('should throw', function () {
+ expect(() => {
+ // @ts-expect-error
+ schema.registerSchema(DRIVER_TYPE, 'whoopee', {$async: true});
+ }).to.throw(
+ SchemaUnsupportedSchemaError,
+ /cannot be an async schema/i,
);
});
});
- describe('when provided a `type` and nonempty `name`, but an invalid `schema`', function () {
+ describe('when the schema is boolean', function () {
it('should throw', function () {
- const schemaObject = [45];
- expect(() =>
- schema.registerSchema('driver', 'whoopeee', schemaObject),
- ).to.throw(/schema is invalid/i);
+ expect(() => {
+ // @ts-expect-error
+ schema.registerSchema(DRIVER_TYPE, 'whoopee', true);
+ }).to.throw(SchemaUnsupportedSchemaError);
+ });
});
});
@@ -110,9 +124,9 @@ describe('schema', function () {
describe('when the schema is identical', function () {
it('should not throw', function () {
const schemaObject = {title: 'whoopee'};
- schema.registerSchema('driver', 'whoopee', schemaObject);
+ schema.registerSchema(DRIVER_TYPE, 'whoopee', schemaObject);
expect(() =>
- schema.registerSchema('driver', 'whoopee', schemaObject),
+ schema.registerSchema(DRIVER_TYPE, 'whoopee', schemaObject),
).not.to.throw();
});
});
@@ -120,9 +134,9 @@ describe('schema', function () {
describe('when the schema is different', function () {
it('should throw', function () {
const schemaObject = {title: 'whoopee'};
- schema.registerSchema('driver', 'whoopee', schemaObject);
+ schema.registerSchema(DRIVER_TYPE, 'whoopee', schemaObject);
expect(() =>
- schema.registerSchema('driver', 'whoopee', {
+ schema.registerSchema(DRIVER_TYPE, 'whoopee', {
title: 'cushion?',
}),
).to.throw(Error, /conflicts with an existing schema/);
@@ -135,7 +149,7 @@ describe('schema', function () {
it('should register the schema', function () {
const schemaObject = {title: 'whoopee'};
expect(() =>
- schema.registerSchema('driver', 'whoopee', schemaObject),
+ schema.registerSchema(DRIVER_TYPE, 'whoopee', schemaObject),
).not.to.throw();
});
@@ -143,9 +157,9 @@ describe('schema', function () {
it('should register both', function () {
const schema1 = {title: 'pro-skub'};
const schema2 = {title: 'anti-skub'};
- schema.registerSchema('driver', 'skub', schema1);
+ schema.registerSchema(DRIVER_TYPE, 'skub', schema1);
expect(() =>
- schema.registerSchema('plugin', 'skub', schema2),
+ schema.registerSchema(PLUGIN_TYPE, 'skub', schema2),
).not.to.throw();
});
});
@@ -205,7 +219,7 @@ describe('schema', function () {
describe('when schema already compiled including an extension', function () {
beforeEach(function () {
- schema.registerSchema('driver', 'stuff', DRIVER_SCHEMA_FIXTURE);
+ schema.registerSchema(DRIVER_TYPE, 'stuff', DRIVER_SCHEMA_FIXTURE);
schema.finalizeSchema();
});
@@ -235,7 +249,7 @@ describe('schema', function () {
describe('when extension schemas include defaults', function () {
it('should return a Record object containing defaults for the extensions', function () {
- schema.registerSchema('driver', 'stuff', DRIVER_SCHEMA_FIXTURE);
+ schema.registerSchema(DRIVER_TYPE, 'stuff', DRIVER_SCHEMA_FIXTURE);
schema.finalizeSchema();
const defaults = schema.getDefaultsForSchema();
// extensions have a key that looks like a keypath. we may want to change that
@@ -268,7 +282,7 @@ describe('schema', function () {
beforeEach(function () {
schema.registerSchema(
- 'driver',
+ DRIVER_TYPE,
'fake',
// TS complains about this require()
// @ts-ignore
@@ -288,7 +302,7 @@ describe('schema', function () {
},
argSpec: {
name: 'silly-web-server-port',
- extType: 'driver',
+ extType: DRIVER_TYPE,
extName: 'fake',
ref: 'driver-fake.json#/properties/silly-web-server-port',
arg: 'driver-fake-silly-web-server-port',
@@ -305,7 +319,7 @@ describe('schema', function () {
},
argSpec: {
name: 'sillyWebServerHost',
- extType: 'driver',
+ extType: DRIVER_TYPE,
extName: 'fake',
ref: 'driver-fake.json#/properties/sillyWebServerHost',
arg: 'driver-fake-silly-web-server-host',
@@ -334,7 +348,7 @@ describe('schema', function () {
describe('when extensions register schemas', function () {
beforeEach(function () {
- schema.registerSchema('driver', 'stuff', DRIVER_SCHEMA_FIXTURE);
+ schema.registerSchema(DRIVER_TYPE, 'stuff', DRIVER_SCHEMA_FIXTURE);
});
it('should return a Record containing all extension schemas _and_ the base schema containing references to the extension schemas', function () {
@@ -429,7 +443,7 @@ describe('schema', function () {
describe('when schema already compiled, with extensions', function () {
beforeEach(function () {
- schema.registerSchema('driver', 'stuff', DRIVER_SCHEMA_FIXTURE);
+ schema.registerSchema(DRIVER_TYPE, 'stuff', DRIVER_SCHEMA_FIXTURE);
schema.finalizeSchema();
});

fix(appium): disallow unsupported schemas

Some theoretical schemas are not in any sort of shape Appium can use, so we now catch them as soon as possible (at the registration phase).

- adds  type guard
- adds