cht-conf 3.11.0 → 3.13.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ # [3.13.0](https://github.com/medic/cht-conf/compare/v3.12.0...v3.13.0) (2022-07-12)
2
+
3
+
4
+ ### Features
5
+
6
+ * **#479:** validate simple XPaths in forms ([#486](https://github.com/medic/cht-conf/issues/486)) ([9df4a63](https://github.com/medic/cht-conf/commit/9df4a630e4bf69d6f6d102c6ae538f9e0bf10282)), closes [#479](https://github.com/medic/cht-conf/issues/479) [#479](https://github.com/medic/cht-conf/issues/479)
7
+
8
+ # [3.12.0](https://github.com/medic/cht-conf/compare/v3.11.1...v3.12.0) (2022-06-23)
9
+
10
+
11
+ ### Features
12
+
13
+ * **#484:** add actions for validating forms ([f2a6a8e](https://github.com/medic/cht-conf/commit/f2a6a8ef75561426ce2a6b542cdf1a255c869c6e)), closes [#484](https://github.com/medic/cht-conf/issues/484) [#481](https://github.com/medic/cht-conf/issues/481)
14
+
15
+ ## [3.11.1](https://github.com/medic/cht-conf/compare/v3.11.0...v3.11.1) (2022-06-13)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **#482:** move-contacts usage docs ([5fa86c8](https://github.com/medic/cht-conf/commit/5fa86c8e98797d8920ee20f2e7f16c98aa969201)), closes [#482](https://github.com/medic/cht-conf/issues/482)
21
+
1
22
  # [3.11.0](https://github.com/medic/cht-conf/compare/v3.10.2...v3.11.0) (2022-04-19)
2
23
 
3
24
 
package/README.md CHANGED
@@ -300,7 +300,6 @@ Execute `npm test` to run static analysis checks and the test suite. Requires Do
300
300
  1. Get the pull request reviewed and approved.
301
301
  1. When doing the squash and merge, make sure that your commit message is clear and readable and follows the strict format described in the commit format section below. If the commit message does not comply, automatic release will fail.
302
302
  1. In case you are planning to merge the pull request with a merge commit, make sure that every commit in your branch respects the format.
303
- 1. Announce the release on the [CHT forum](https://forum.communityhealthtoolkit.org), under the "Product - Releases" category.
304
303
 
305
304
  ### Commit format
306
305
  The commit format should follow this [conventional-changelog angular preset](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular). Examples are provided below.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cht-conf",
3
- "version": "3.11.0",
3
+ "version": "3.13.0",
4
4
  "description": "Configure CHT deployments",
5
5
  "main": "./src/lib/main.js",
6
6
  "engines": {
@@ -11,7 +11,7 @@
11
11
  "eslint": "eslint 'src/**/*.js' test/*.js 'test/**/*.js'",
12
12
  "docker-start-couchdb": "npm run docker-stop-couchdb && docker run -d -p 6984:5984 --rm --name cht-conf-couchdb couchdb:2.3.1 && sh test/scripts/wait_for_response_code.sh 6984 200 CouchDB",
13
13
  "docker-stop-couchdb": "docker stop cht-conf-couchdb || true",
14
- "test": "npm run eslint && npm run docker-start-couchdb && npm run clean && mkdir -p build/test && cp -r test/data build/test/data && cd build/test && nyc --reporter=html mocha --forbid-only ../../test/**/*.spec.js && cd ../.. && npm run docker-stop-couchdb",
14
+ "test": "npm run eslint && npm run docker-start-couchdb && npm run clean && mkdir -p build/test && cp -r test/data build/test/data && cd build/test && nyc --reporter=html mocha --forbid-only \"../../test/**/*.spec.js\" && cd ../.. && npm run docker-stop-couchdb",
15
15
  "semantic-release": "semantic-release"
16
16
  },
17
17
  "bin": {
@@ -38,6 +38,7 @@
38
38
  "@hapi/joi": "^16.1.8",
39
39
  "@medic/translation-checker": "^1.0.1",
40
40
  "@parcel/watcher": "^2.0.5",
41
+ "@xmldom/xmldom": "^0.8.2",
41
42
  "canonical-json": "0.0.4",
42
43
  "csv-parse": "^4.16.0",
43
44
  "dom-compare": "^0.6.0",
@@ -67,7 +68,7 @@
67
68
  "terser-webpack-plugin": "^1.4.3",
68
69
  "uuid": "^8.3.2",
69
70
  "webpack": "^4.46.0",
70
- "xmldom": "^0.6.0"
71
+ "xpath": "0.0.32"
71
72
  },
72
73
  "devDependencies": {
73
74
  "@commitlint/cli": "^13.2.0",
package/src/cli/usage.js CHANGED
@@ -63,6 +63,9 @@ ${bold('OPTIONS')}
63
63
  --skip-translation-check
64
64
  Skips checking message translations
65
65
 
66
+ --skip-validate
67
+ Skips form validation
68
+
66
69
  --force
67
70
  CAN BE DANGEROUS! Passes yes to all commands and any where that would prompt to overwrite changes will overwrite automatically.
68
71
  `);
@@ -101,7 +101,7 @@ const parseExtraArgs = (projectDir, extraArgs = []) => {
101
101
 
102
102
  if (contactIds.length === 0) {
103
103
  usage();
104
- throw Error('Action "move-contacts" is missing required list of contact_id to be moved');
104
+ throw Error('Action "move-contacts" is missing required list of contacts to be moved');
105
105
  }
106
106
 
107
107
  if (!args.parent) {
@@ -137,7 +137,7 @@ ${bold('cht-conf\'s move-contacts action')}
137
137
  When combined with 'upload-docs' this action effectively moves a contact from one place in the hierarchy to another.
138
138
 
139
139
  ${bold('USAGE')}
140
- cht --local move-contacts -- --contactIds=<id1>,<id2> --parent=<parent_id>
140
+ cht --local move-contacts -- --contacts=<id1>,<id2> --parent=<parent_id>
141
141
 
142
142
  ${bold('OPTIONS')}
143
143
  --contacts=<id1>,<id2>
@@ -0,0 +1,12 @@
1
+ const validateForms = require('../lib/validate-forms');
2
+ const environment = require('../lib/environment');
3
+
4
+ const validateAppForms = (forms) => {
5
+ return validateForms(environment.pathToProject, 'app', { forms });
6
+ };
7
+
8
+ module.exports = {
9
+ requiresInstance: false,
10
+ validateAppForms,
11
+ execute: () => validateAppForms(environment.extraArgs)
12
+ };
@@ -0,0 +1,12 @@
1
+ const validateForms = require('../lib/validate-forms');
2
+ const environment = require('../lib/environment');
3
+
4
+ const validateCollectForms = (forms) => {
5
+ return validateForms(environment.pathToProject, 'collect', { forms });
6
+ };
7
+
8
+ module.exports = {
9
+ requiresInstance: false,
10
+ validateCollectForms,
11
+ execute: () => validateCollectForms(environment.extraArgs)
12
+ };
@@ -0,0 +1,12 @@
1
+ const validateForms = require('../lib/validate-forms');
2
+ const environment = require('../lib/environment');
3
+
4
+ const validateContactForms = (forms) => {
5
+ return validateForms(environment.pathToProject, 'contact', { forms });
6
+ };
7
+
8
+ module.exports = {
9
+ requiresInstance: false,
10
+ validateContactForms,
11
+ execute: () => validateContactForms(environment.extraArgs)
12
+ };
@@ -5,6 +5,9 @@ const fs = require('fs');
5
5
  const { error, warn, info } = require('../lib/log');
6
6
  const Queue = require('queue-promise');
7
7
  const watcher = require('@parcel/watcher');
8
+ const { validateAppForms } = require('./validate-app-forms');
9
+ const { validateContactForms } = require('./validate-contact-forms');
10
+ const { validateCollectForms } = require('./validate-collect-forms');
8
11
  const { uploadAppForms } = require('./upload-app-forms');
9
12
  const { uploadContactForms } = require('./upload-contact-forms');
10
13
  const { uploadCollectForms } = require('./upload-collect-forms');
@@ -25,8 +28,17 @@ const watcherEvents = {
25
28
  UpdateEvent: 'update'
26
29
  };
27
30
 
31
+ const runValidation = (validation, forms) => {
32
+ if(environment.skipValidate) {
33
+ return;
34
+ }
35
+ return validation(forms);
36
+ };
37
+
28
38
  const uploadInitialState = async (api) => {
29
39
  await uploadResources();
40
+ await runValidation(validateAppForms, environment.extraArgs);
41
+ await runValidation(validateContactForms, environment.extraArgs);
30
42
  await uploadAppForms(environment.extraArgs);
31
43
  await uploadContactForms(environment.extraArgs);
32
44
  await uploadCustomTranslations();
@@ -82,6 +94,7 @@ const processAppForm = (eventType, fileName) => {
82
94
  let form = uploadForms.formFileMatcher(fileName);
83
95
  if (form) {
84
96
  eventQueue.enqueue(async () => {
97
+ await runValidation(validateAppForms, [form]);
85
98
  await uploadAppForms([form]);
86
99
  return fileName;
87
100
  });
@@ -103,6 +116,7 @@ const processAppFormMedia = (formMediaDir, fileName) => {
103
116
  const form = uploadForms.formMediaMatcher(formMediaDir);
104
117
  if (form) {
105
118
  eventQueue.enqueue(async () => {
119
+ await runValidation(validateAppForms,[form]);
106
120
  await uploadAppForms([form]);
107
121
  return fileName;
108
122
  });
@@ -127,6 +141,7 @@ const processContactForm = (eventType, fileName) => {
127
141
  form = uploadForms.formFileMatcher(fileName);
128
142
  if (form) {
129
143
  eventQueue.enqueue(async () => {
144
+ await runValidation(validateContactForms,[form]);
130
145
  await uploadContactForms([form]);
131
146
  return fileName;
132
147
  });
@@ -142,6 +157,7 @@ const processCollectForm = (eventType, fileName) => {
142
157
  let form = uploadForms.formFileMatcher(fileName);
143
158
  if (form) {
144
159
  eventQueue.enqueue(async () => {
160
+ await runValidation(validateCollectForms,[form]);
145
161
  await uploadCollectForms([form]);
146
162
  return fileName;
147
163
  });
@@ -12,7 +12,8 @@ const initialize = (
12
12
  extraArgs,
13
13
  apiUrl,
14
14
  force,
15
- skipTranslationCheck
15
+ skipTranslationCheck,
16
+ skipValidate
16
17
  ) => {
17
18
  if (state.initialized) {
18
19
  throw Error('environment is already initialized');
@@ -26,7 +27,8 @@ const initialize = (
26
27
  isArchiveMode,
27
28
  pathToProject,
28
29
  force,
29
- skipTranslationCheck
30
+ skipTranslationCheck,
31
+ skipValidate
30
32
  });
31
33
  };
32
34
 
@@ -51,6 +53,7 @@ module.exports = {
51
53
  get apiUrl() { return getState('apiUrl'); },
52
54
  get force() { return getState('force'); },
53
55
  get skipTranslationCheck() { return getState('skipTranslationCheck'); },
56
+ get skipValidate() { return getState('skipValidate'); },
54
57
 
55
58
  /**
56
59
  * Return `true` if the environment **seems** to be production.
@@ -1,5 +1,14 @@
1
+ const xpath = require('xpath');
1
2
  const fs = require('./sync-fs');
2
3
 
4
+ const XPATH_MODEL = '/h:html/h:head/model';
5
+
6
+ const getNode = (currentNode, path) =>
7
+ xpath.parse(path).select1({ node: currentNode, allowAnyNamespaceForNoPrefix: true });
8
+
9
+ const getNodes = (currentNode, path) =>
10
+ xpath.parse(path).select({ node: currentNode, allowAnyNamespaceForNoPrefix: true });
11
+
3
12
  module.exports = {
4
13
  /**
5
14
  * Get the full path of the form, or null if the path doesn't exist.
@@ -29,24 +38,53 @@ module.exports = {
29
38
  };
30
39
  },
31
40
 
32
- // This isn't really how to parse XML, but we have fairly good control over the
33
- // input and this code is working so far. This may break with changes to the
34
- // formatting of output from xls2xform.
41
+ /**
42
+ * Returns the node from the form XML specified by the given XPath.
43
+ * @param {Element} currentNode the current node in the form XML document
44
+ * @param {string} path the XPath expression
45
+ * @returns {Element} the selected node or `undefined` if not found
46
+ */
47
+ getNode,
48
+
49
+ /**
50
+ * Returns the nodes from the form XML specified by the given XPath.
51
+ * @param {Element} currentNode the current node in the form XML document
52
+ * @param {string} path the XPath expression
53
+ * @returns {Element} the selected nodes or an empty array if none are found
54
+ */
55
+ getNodes,
56
+
57
+ /**
58
+ * Returns the `bind` nodes for the given form XML.
59
+ * @param {Document} xmlDoc the form XML document
60
+ * @returns {Element}
61
+ */
62
+ getBindNodes: xmlDoc => getNodes(xmlDoc, `${XPATH_MODEL}/bind`),
63
+
64
+ /**
65
+ * Returns the primary (first) `instance` node for the given form XML.
66
+ * @param {Document} xmlDoc the form XML document
67
+ * @returns {Element}
68
+ */
69
+ getPrimaryInstanceNode: xmlDoc => getNode(xmlDoc, `${XPATH_MODEL}/instance`),
35
70
 
36
71
  /**
37
72
  * Check whether the XForm has the <instanceID/> tag.
38
- * @param {string} xml the XML string
73
+ * @param {string} xmlDoc the XML document
39
74
  * @returns {boolean}
40
75
  */
41
- formHasInstanceId: xml => xml.includes('<instanceID/>'),
76
+ formHasInstanceId: xmlDoc => getNode(xmlDoc, `//meta/instanceID`) !== undefined,
42
77
 
78
+ // This isn't really how to parse XML, but we have fairly good control over the
79
+ // input and this code is working so far. This may break with changes to the
80
+ // formatting of output from xls2xform.
43
81
  /**
44
82
  * Get the title string inside the <h:title> tag
45
83
  * @param {string} xml the XML string
46
84
  * @returns {string}
47
85
  */
48
86
  readTitleFrom: xml =>
49
- xml.substring(xml.indexOf('<h:title>') + 9, xml.indexOf('</h:title>')),
87
+ xml.substring(xml.indexOf('<h:title>') + 9, xml.indexOf('</h:title>')),
50
88
 
51
89
  /**
52
90
  * Get the ID of the form
@@ -54,7 +92,7 @@ module.exports = {
54
92
  * @returns {string}
55
93
  */
56
94
  readIdFrom: xml =>
57
- xml.match(/<model>[^]*<\/model>/)[0]
58
- .match(/<instance>[^]*<\/instance>/)[0]
59
- .match(/id="([^"]*)"/)[1],
95
+ xml.match(/<model>[^]*<\/model>/)[0]
96
+ .match(/<instance>[^]*<\/instance>/)[0]
97
+ .match(/id="([^"]*)"/)[1],
60
98
  };
package/src/lib/main.js CHANGED
@@ -21,6 +21,9 @@ const defaultActions = [
21
21
  'convert-app-forms',
22
22
  'convert-collect-forms',
23
23
  'convert-contact-forms',
24
+ 'validate-app-forms',
25
+ 'validate-collect-forms',
26
+ 'validate-contact-forms',
24
27
  'backup-all-forms',
25
28
  'delete-all-forms',
26
29
  'upload-app-forms',
@@ -38,6 +41,9 @@ const defaultArchiveActions = [
38
41
  'convert-app-forms',
39
42
  'convert-collect-forms',
40
43
  'convert-contact-forms',
44
+ 'validate-app-forms',
45
+ 'validate-collect-forms',
46
+ 'validate-contact-forms',
41
47
  'upload-app-forms',
42
48
  'upload-collect-forms',
43
49
  'upload-contact-forms',
@@ -123,19 +129,40 @@ module.exports = async (argv, env) => {
123
129
  // Build up actions
124
130
  //
125
131
  let actions = cmdArgs._;
126
- if (!actions.length) {
132
+ if (actions.length) {
133
+ const unsupported = actions.filter(a => !supportedActions.includes(a));
134
+ if(unsupported.length) {
135
+ throw new Error(`Unsupported action(s): ${unsupported.join(' ')}`);
136
+ }
137
+ } else {
127
138
  actions = !cmdArgs.archive ? defaultActions : defaultArchiveActions;
128
139
  }
129
140
 
130
- const unsupported = actions.filter(a => !supportedActions.includes(a));
131
- if(unsupported.length) {
132
- throw new Error(`Unsupported action(s): ${unsupported.join(' ')}`);
133
- }
134
-
135
141
  if (cmdArgs['skip-git-check']) {
136
142
  actions = actions.filter(a => a !== 'check-git');
137
143
  }
138
144
 
145
+ const skipValidate = cmdArgs['skip-validate'];
146
+ if(skipValidate) {
147
+ warn('Skipping all form validation.');
148
+ const validateActions = [
149
+ 'validate-app-forms',
150
+ 'validate-collect-forms',
151
+ 'validate-contact-forms'
152
+ ];
153
+ actions = actions.filter(action => !validateActions.includes(action));
154
+ } else {
155
+ const addFormValidationIfNecessary = (formType) => {
156
+ const updateFormsIndex = actions.indexOf(`upload-${formType}-forms`);
157
+ if (updateFormsIndex >= 0 && actions.indexOf(`validate-${formType}-forms`) < 0) {
158
+ actions.splice(updateFormsIndex, 0, `validate-${formType}-forms`);
159
+ }
160
+ };
161
+ addFormValidationIfNecessary('app');
162
+ addFormValidationIfNecessary('collect');
163
+ addFormValidationIfNecessary('contact');
164
+ }
165
+
139
166
  actions = actions.map(actionName => {
140
167
  const action = require(`../fn/${actionName}`);
141
168
 
@@ -157,9 +184,9 @@ module.exports = async (argv, env) => {
157
184
  //
158
185
  const projectName = fs.path.basename(pathToProject);
159
186
 
160
- let apiUrl;
161
- if (actions.some(action => action.requiresInstance)) {
162
- apiUrl = getApiUrl(cmdArgs, env);
187
+ const apiUrl = getApiUrl(cmdArgs, env);
188
+ const requiresInstance = actions.some(action => action.requiresInstance);
189
+ if (requiresInstance) {
163
190
  if (!apiUrl) {
164
191
  throw new Error('Failed to obtain a url to the API');
165
192
  }
@@ -177,10 +204,11 @@ module.exports = async (argv, env) => {
177
204
  extraArgs,
178
205
  apiUrl,
179
206
  cmdArgs.force,
180
- cmdArgs['skip-translation-check']
207
+ cmdArgs['skip-translation-check'],
208
+ skipValidate
181
209
  );
182
210
 
183
- if (apiUrl) {
211
+ if (requiresInstance && apiUrl) {
184
212
  await api().available();
185
213
  }
186
214
 
@@ -13,7 +13,6 @@ const {
13
13
  readTitleFrom,
14
14
  readIdFrom
15
15
  } = require('./forms-utils');
16
- const validateForms = require('./validate-forms');
17
16
 
18
17
  const SUPPORTED_PROPERTIES = ['context', 'icon', 'title', 'xml2sms', 'subject_key', 'hidden_fields'];
19
18
  const FORM_EXTENSTION = '.xml';
@@ -40,7 +39,6 @@ const formMediaMatcher = (formMediaDir) => {
40
39
  };
41
40
 
42
41
  const execute = async (projectDir, subDirectory, options) => {
43
- await validateForms(projectDir, subDirectory, options);
44
42
  const db = pouch();
45
43
  if (!options) options = {};
46
44
  const formsDir = getFormDir(projectDir, subDirectory);
@@ -1,64 +1,84 @@
1
- const api = require('./api');
1
+ const { DOMParser } = require('@xmldom/xmldom');
2
2
  const argsFormFilter = require('./args-form-filter');
3
+ const environment = require('./environment');
3
4
  const fs = require('./sync-fs');
4
5
  const log = require('./log');
5
6
  const {
6
7
  getFormDir,
7
- getFormFilePaths,
8
- formHasInstanceId
8
+ getFormFilePaths
9
9
  } = require('./forms-utils');
10
10
 
11
+ const domParser = new DOMParser();
12
+ const VALIDATIONS_PATH = fs.path.resolve(__dirname, './validation/form');
13
+ const validations = fs.readdir(VALIDATIONS_PATH)
14
+ .filter(name => name.endsWith('.js'))
15
+ .map(validationName => {
16
+ const validation = require(fs.path.join(VALIDATIONS_PATH, validationName));
17
+ validation.name = validationName;
18
+ if(!Object.hasOwnProperty.call(validation, 'requiresInstance')) {
19
+ validation.requiresInstance = true;
20
+ }
21
+ if(!Object.hasOwnProperty.call(validation, 'skipFurtherValidation')) {
22
+ validation.skipFurtherValidation = false;
23
+ }
24
+
25
+ return validation;
26
+ })
27
+ .sort((a, b) => {
28
+ if(a.skipFurtherValidation && !b.skipFurtherValidation) {
29
+ return -1;
30
+ }
31
+ if(!a.skipFurtherValidation && b.skipFurtherValidation) {
32
+ return 1;
33
+ }
34
+ return a.name.localeCompare(b.name);
35
+ });
36
+
11
37
  module.exports = async (projectDir, subDirectory, options={}) => {
12
38
 
13
39
  const formsDir = getFormDir(projectDir, subDirectory);
14
40
  if(!formsDir) {
15
- log.info(`Forms dir not found: ${formsDir}`);
41
+ log.info(`Forms dir not found: ${projectDir}/forms/${subDirectory}`);
16
42
  return;
17
43
  }
18
44
 
19
- let idValidationsPassed = true;
20
- let validateFormsPassed = true;
45
+ const instanceProvided = environment.apiUrl;
46
+ let validationSkipped = false;
21
47
 
22
48
  const fileNames = argsFormFilter(formsDir, '.xml', options);
23
- for (const fileName of fileNames) {
49
+
50
+ let errorFound = false;
51
+ for(const fileName of fileNames) {
24
52
  log.info(`Validating form: ${fileName}…`);
25
53
 
26
- const { xformPath, filePath } = getFormFilePaths(formsDir, fileName);
54
+ const { xformPath } = getFormFilePaths(formsDir, fileName);
27
55
  const xml = fs.read(xformPath);
28
56
 
29
- if(!formHasInstanceId(xml)) {
30
- log.error(`Form at ${xformPath} appears to be missing <meta><instanceID/></meta> node. This form will not work on CHT webapp.`);
31
- idValidationsPassed = false;
32
- continue;
33
- }
34
- try {
35
- await _formsValidate(xml);
36
- } catch (err) {
37
- log.error(`Error found while validating "${filePath}". Validation response: ${err.message}`);
38
- validateFormsPassed = false;
57
+ const valParams = { xformPath, xmlStr: xml, xmlDoc: domParser.parseFromString(xml) };
58
+ for(const validation of validations) {
59
+ if(validation.requiresInstance && !instanceProvided) {
60
+ validationSkipped = true;
61
+ continue;
62
+ }
63
+
64
+ const output = await validation.execute(valParams);
65
+ if(output.warnings) {
66
+ output.warnings.forEach(warnMsg => log.warn(warnMsg));
67
+ }
68
+ if(output.errors && output.errors.length) {
69
+ output.errors.forEach(errorMsg => log.error(errorMsg));
70
+ errorFound = true;
71
+ if(validation.skipFurtherValidation) {
72
+ break;
73
+ }
74
+ }
39
75
  }
40
76
  }
41
- // Once all the fails were checked raise an exception if there were errors
42
- let errors = [];
43
- if (!validateFormsPassed) {
44
- errors.push('One or more forms appears to have errors found by the API validation endpoint.');
45
- }
46
- if (!idValidationsPassed) {
47
- errors.push('One or more forms appears to be missing <meta><instanceID/></meta> node.');
48
- }
49
- if (errors.length) {
50
- // the blank spaces are a trick to align the errors in the log ;)
51
- throw new Error(errors.join('\n '));
52
- }
53
- };
54
77
 
55
- let validateEndpointNotFoundLogged = false;
56
- const _formsValidate = async (xml) => {
57
- const resp = await api().formsValidate(xml);
58
- if (resp.formsValidateEndpointFound === false && !validateEndpointNotFoundLogged) {
59
- log.warn('Form validation endpoint not found in your version of CHT Core, ' +
60
- 'no form will be checked before push');
61
- validateEndpointNotFoundLogged = true; // Just log the message once
78
+ if(validationSkipped) {
79
+ log.warn('Some validations have been skipped because they require a CHT instance.');
80
+ }
81
+ if(errorFound) {
82
+ throw new Error('One or more forms have failed validation.');
62
83
  }
63
- return resp;
64
84
  };
@@ -0,0 +1,32 @@
1
+ # Form validations
2
+
3
+ Form validations should be added to this directory.
4
+
5
+ Their module should look something like this:
6
+
7
+ ```js
8
+ module.exports = {
9
+ requiresInstance: false,
10
+ skipFurtherValidation: true,
11
+ execute: async ({ xformPath, xmlStr }) => {
12
+ ...
13
+ }
14
+ };
15
+ ```
16
+
17
+ ## Module Exports
18
+
19
+ | Field | Required | Notes |
20
+ |-------------------------|--------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
21
+ | `requiresInstance` | Optional, defaults to `true` | The validation needs the user to have provided a instance location, e.g. via `--local` or `--instance` |
22
+ | `skipFurtherValidation` | Optional, defaults to `false` | When `true`, additional validations will not be performed on a form that fails the current validation. The goal is to avoid unnecessary noise in the validation log for forms that are grossly invalid (e.g. XML files that are not actually xForms at all). |
23
+ | `execute` | Required | The function that is run when the validation is executed. The provided input argument contains the form XML string. |
24
+
25
+ ## Result
26
+
27
+ The result has the following format:
28
+
29
+ | Field | Notes |
30
+ |----------|----------------------------------------------------------------------------------------------------|
31
+ | warnings | Array containing the descriptions of any warnings. |
32
+ | errors | Array containing the descriptions of any errors. If this is empty, the form has passed validation. |
@@ -0,0 +1,18 @@
1
+ const api = require('../../api');
2
+
3
+ module.exports = {
4
+ execute: async ({ xformPath, xmlStr }) => {
5
+ const warnings = [];
6
+ const errors = [];
7
+ try {
8
+ const resp = await api().formsValidate(xmlStr);
9
+ if (resp.formsValidateEndpointFound === false) {
10
+ warnings.push('Form validation endpoint not found in your version of CHT Core, no form will be checked before push');
11
+ }
12
+ } catch (err) {
13
+ errors.push(`Error found while validating "${xformPath}". Validation response: ${err.message}`);
14
+ }
15
+
16
+ return { errors, warnings };
17
+ }
18
+ };
@@ -0,0 +1,121 @@
1
+ /*
2
+ An XPath expression that evaluates to an empty result (e.g. no node is found in the document that matches the
3
+ expression) is technically "valid" (in the sense that it is a properly formatted XPath expression).
4
+
5
+ However, if there is a "simple" XPath expression (one that just references a node via an absolute/relative path and does
6
+ not contain any dynamic processing) in a form that does not evaluate to an actual node in the form, that is almost
7
+ certainly an error (since it could never return any other result). This validation checks the simple XPaths in a form's
8
+ calculate, constraint, readonly, relevant, and required expressions and returns an error for any simple XPaths that
9
+ point to a non-existant node. More complex dynamic XPath expressions are not validated (since their results could vary
10
+ at runtime depending on the data in the form).
11
+
12
+ https://github.com/medic/cht-conf/issues/479
13
+ */
14
+
15
+ const { getNode, getBindNodes, getPrimaryInstanceNode } = require('../../forms-utils');
16
+
17
+ // Characters used in simple XPaths
18
+ const SUPPORTED_CHAR = '[/\\w.-]';
19
+ // Special characters used in complex XPaths
20
+ const UNSUPPORTED_CHAR = '[*@:[\\]]';
21
+ const XPATH_CHAR = `(${SUPPORTED_CHAR}|${UNSUPPORTED_CHAR})`;
22
+ // Some XPaths lookup nodes in separate instances (e.g. instance('contact-summary')/context/pregnancy_uuid)
23
+ const INSTANCE = `instance\\([\\w-'"]+\\)`;
24
+ // Look ahead and make sure there are an even number of quotes following the match (this means that the match itself is not in quotes).
25
+ const LOOK_AHEADS_FOR_EVEN_QUOTES = `(?=([^"]*"[^"]*")*[^"]*$)(?=([^']*'[^']*')*[^']*$)`;
26
+ // Matches on all possible XPaths (simple and complex) not in quotes. May start with an instance reference. Must include a slash.
27
+ const XPATH_PATTERN = new RegExp(`(${INSTANCE}|)${XPATH_CHAR}*\\/${XPATH_CHAR}+${LOOK_AHEADS_FOR_EVEN_QUOTES}`, 'g');
28
+ // Matches on XPaths containing complex calculations (note that '//' indicates a deep lookup, which we do not validate)
29
+ const UNSUPPORTED_XPATH_PATTERN = new RegExp(`\\/\\/|${UNSUPPORTED_CHAR}|${INSTANCE}`, 'g');
30
+
31
+ const extractSimpleXpaths = (expression) => {
32
+ /*
33
+ "Simple" XPaths look a lot like subsections of complex XPaths. To ensure that we never accidentally extract part of a
34
+ complex XPath, we first just match on all possible XPaths (simple and complex). Then we do a second pass to filter the
35
+ list down to just the XPaths that have a supported beginning section (since that is a cheep check). Finally, we filter
36
+ out any complex XPaths (with unsupported characters).
37
+ */
38
+ return (expression.match(XPATH_PATTERN) || [])
39
+ .filter(xpath => xpath.startsWith('/') || xpath.startsWith('./') || xpath.startsWith('../'))
40
+ .filter(xpath => !xpath.match(UNSUPPORTED_XPATH_PATTERN));
41
+ };
42
+
43
+ const isValidXpath = (nodeset, xpathToValidate, instance) => {
44
+ if(xpathToValidate.startsWith('/')) {
45
+ // Absolute XPath. Evaluate it relative to the instance
46
+ return getNode(instance, xpathToValidate.substring(1));
47
+ }
48
+ // Relative XPath. Evaluate it relative to the current node
49
+ const currentNode = getNode(instance, nodeset.substring(1));
50
+ if(!currentNode) {
51
+ throw new Error(`Could not find model node referenced by bind nodeset: ${nodeset}`);
52
+ }
53
+ return getNode(currentNode, xpathToValidate);
54
+ };
55
+
56
+ const extractInvalidXPaths = (nodeset, expression, instance) => {
57
+ return extractSimpleXpaths(expression)
58
+ .filter(xpath => !isValidXpath(nodeset, xpath, instance));
59
+ };
60
+
61
+ const getField = (bind, instance) => {
62
+ const nodeset = bind.getAttribute('nodeset');
63
+ const getInvalidXPaths = (expression) => extractInvalidXPaths(nodeset, expression, instance);
64
+ return {
65
+ nodeset,
66
+ calculate: getInvalidXPaths(bind.getAttribute('calculate')),
67
+ constraint: getInvalidXPaths(bind.getAttribute('constraint')),
68
+ readonly: getInvalidXPaths(bind.getAttribute('readonly')),
69
+ relevant: getInvalidXPaths(bind.getAttribute('relevant')),
70
+ required: getInvalidXPaths(bind.getAttribute('required')),
71
+ };
72
+ };
73
+
74
+ const keepFieldsWithXPaths = (field) => {
75
+ return field.calculate.length ||
76
+ field.constraint.length ||
77
+ field.readonly.length ||
78
+ field.relevant.length ||
79
+ field.required.length;
80
+ };
81
+
82
+ const getFieldsWithInvalidXPaths = (xmlDoc) => {
83
+ const instance = getPrimaryInstanceNode(xmlDoc);
84
+ if(!instance) {
85
+ throw new Error('No instance found in form XML.');
86
+ }
87
+ return getBindNodes(xmlDoc)
88
+ .map(bind => getField(bind, instance))
89
+ .filter(field => keepFieldsWithXPaths(field));
90
+ };
91
+
92
+ module.exports = {
93
+ requiresInstance: false,
94
+ skipFurtherValidation: false,
95
+ execute: async({ xformPath, xmlDoc }) => {
96
+ const errors = [];
97
+ try {
98
+ const fields = getFieldsWithInvalidXPaths(xmlDoc);
99
+ if(fields.length) {
100
+ errors.push(`Form at ${xformPath} contains invalid XPath expressions (absolute or relative paths that refer to a non-existant node):`);
101
+
102
+ fields.forEach(field => {
103
+ const recordError = (expressionName) => {
104
+ const xpaths = field[expressionName];
105
+ if(xpaths.length) {
106
+ errors.push(` - ${expressionName} for ${field.nodeset} contains [${xpaths.join(', ')}]`);
107
+ }
108
+ };
109
+ recordError('calculate');
110
+ recordError('constraint');
111
+ recordError('readonly');
112
+ recordError('relevant');
113
+ recordError('required');
114
+ });
115
+ }
116
+ } catch(e) {
117
+ errors.push(`Error encountered while validating XPaths in form at ${xformPath}: ${e.message}`);
118
+ }
119
+ return { errors, warnings: [] };
120
+ }
121
+ };
@@ -0,0 +1,14 @@
1
+ const { formHasInstanceId } = require('../../forms-utils');
2
+
3
+ module.exports = {
4
+ requiresInstance: false,
5
+ skipFurtherValidation: true,
6
+ execute: async({ xformPath, xmlDoc }) => {
7
+ const errors = [];
8
+ if(!formHasInstanceId(xmlDoc)) {
9
+ errors.push(`Form at ${xformPath} appears to be missing <meta><instanceID/></meta> node. This form will not work on CHT webapp.`);
10
+ }
11
+
12
+ return { errors, warnings: [] };
13
+ }
14
+ };
@@ -8,7 +8,7 @@ const path = require('path');
8
8
  const log = require('./log');
9
9
  const environment = require('./environment');
10
10
  const { compare, GroupingReporter } = require('dom-compare');
11
- const DOMParser = require('xmldom').DOMParser;
11
+ const DOMParser = require('@xmldom/xmldom').DOMParser;
12
12
 
13
13
  const question = 'You are trying to modify a configuration that has been modified since your last upload. Do you want to?';
14
14
  const responseChoicesWithoutDiff = [