Skip to content

Commit c84c09e

Browse files
authored
Fixes/html entities in version (#1693)
* Fixes getodk/central#1470: decode html entities in form version + fix _versionSplicer * handle the case when version value is either delimited by single quote or no quote + more test data
1 parent e819ca0 commit c84c09e

File tree

5 files changed

+115
-14
lines changed

5 files changed

+115
-14
lines changed

lib/data/schema.js

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const { always, equals, last, map } = require('ramda');
2121
const ptr = last; // just rename to make it more relevant to our context.
2222
const hparser = require('htmlparser2');
2323
const { parse } = require('csv-parse/sync');
24-
const { decodeXML } = require('entities');
24+
const { decodeXML, encodeXML } = require('entities');
2525
const semverSatisfies = require('semver/functions/satisfies');
2626
const Option = require('../util/option');
2727
const Problem = require('../util/problem');
@@ -542,18 +542,10 @@ const _versionSplicer = (replace) => (xml, insert) => new Promise((pass, fail) =
542542
if ((stripNamespacesFromPath(name) === 'version') && (stack.length) === 4 &&
543543
(stack[0] === 'html') && (stack[1] === 'head') && (stack[2] === 'model') &&
544544
(stack[3] === 'instance')) {
545-
// okay, we have found the thing we are looking for.
546-
// idx points at the position of the closing "
547-
//
548-
// TODO: we cheat here and reference the hp2 internal tokenizer index to find
549-
// out where the attribute actually is. the parser startIndex and endIndex point
550-
// at the whitespace preceding the tag until the tag is closed. obviously this is
551-
// pretty bad but i don't see a more robust solution right now.
552-
const idx = parser.tokenizer.index;
545+
const newValue = encodeXML(replace ? insert : `${value}${insert}`);
546+
const result = `${xml.slice(0, parser.startIndex)}${name}="${newValue}"${xml.slice(parser.endIndex)}`;
553547
parser.reset();
554-
return replace
555-
? pass(`${xml.slice(0, idx - value.length)}${insert}${xml.slice(idx)}`)
556-
: pass(`${xml.slice(0, idx)}${insert}${xml.slice(idx)}`);
548+
return pass(result);
557549
}
558550
},
559551
// n.b. opentag happens AFTER all the attributes for that tag have been emitted!
@@ -565,7 +557,7 @@ const _versionSplicer = (replace) => (xml, insert) => new Promise((pass, fail) =
565557

566558
const idx = _findSplicePoint(xml, parser.endIndex);
567559
parser.reset(); // stop parsing.
568-
return pass(`${xml.slice(0, idx)} version="${insert}"${xml.slice(idx)}`);
560+
return pass(`${xml.slice(0, idx)} version="${encodeXML(insert)}"${xml.slice(idx)}`);
569561
}
570562
},
571563
onclosetag: () => {

lib/model/frames/form.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ class Form extends Frame.define(
122122
const xmlFormId = idText.map(blankStringToNull)
123123
.orThrow(Problem.user.missingParameter({ field: 'formId' }));
124124

125-
const version = versionText.orElse('');
125+
const version = versionText.map(decodeXML).orElse('');
126126
const name = nameText.map(decodeXML).orNull();
127127
const key = pubKey.map((k) => new Key({ public: k }));
128128
return new Form.Partial({ xmlFormId, name }, { def: new Form.Def({ version, name }), key });

test/integration/api/forms/draft.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1936,6 +1936,17 @@ describe('api: /projects/:id/forms (drafts)', () => {
19361936
});
19371937
})))));
19381938

1939+
it('should update version even if version provided in query parameter has special chars', testService((service) =>
1940+
service.login('alice', (asAlice) =>
1941+
asAlice.post('/v1/projects/1/forms/simple/draft')
1942+
.expect(200)
1943+
.then(() => asAlice.post('/v1/projects/1/forms/simple/draft/publish?version=x" version%3D"y')
1944+
.expect(200)
1945+
.then(() => asAlice.get('/v1/projects/1/forms/simple')
1946+
.then(({ body }) => {
1947+
body.version.should.eql('x" version="y');
1948+
}))))));
1949+
19391950
it('should log the correct def ids in the form audit log', testService(async (service, { Forms }) => {
19401951
const asAlice = await service.login('alice');
19411952

test/integration/api/forms/forms.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,19 @@ describe('api: /projects/:id/forms (create, read, update)', () => {
9292
body.hash.should.equal('07ed8a51cc3f6472b7dfdc14c2005861');
9393
}))));
9494

95+
it('should decode html entities in the value of form version - getodk/central#1470', testService((service) =>
96+
service.login('alice', (asAlice) =>
97+
asAlice.post('/v1/projects/1/forms')
98+
.send(testData.forms.simple2.replace('2.1', '<{}>'))
99+
.set('Content-Type', 'application/xml')
100+
.expect(200)
101+
.then(({ body }) => {
102+
body.should.be.a.Form();
103+
body.name.should.equal('Simple 2');
104+
body.version.should.equal('<{}>');
105+
body.hash.should.equal('232ffd287a8eaef0103ad2386dafa44e');
106+
}))));
107+
95108
it('should reject if form id ends in .xml', testService((service) =>
96109
service.login('alice', (asAlice) =>
97110
asAlice.post('/v1/projects/1/forms')

test/unit/data/schema.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2020,6 +2020,53 @@ describe('form schema', () => {
20202020
</h:body>
20212021
</h:html>`)));
20222022

2023+
const arabicWord = 'صادق';
2024+
[
2025+
/* eslint-disable no-multi-spaces, key-spacing */
2026+
{ versionAttribute: '', suffix: 'test', expected: 'test' },
2027+
{ versionAttribute: 'version=""', suffix: 'test', expected: 'test' },
2028+
{ versionAttribute: 'version="first"', suffix: '', expected: 'first' },
2029+
{ versionAttribute: 'version=\'first\'', suffix: 'test', expected: 'firsttest' },
2030+
{ versionAttribute: 'version="2.1"', suffix: 'test', expected: '2.1test' },
2031+
{ versionAttribute: 'version = "final"', suffix: ' (working)', expected: 'final (working)' },
2032+
{ versionAttribute: 'version=1', suffix: '.0', expected: '1.0' },
2033+
{ versionAttribute: 'version=1.0', suffix: '.1', expected: '1.0.1' },
2034+
{ versionAttribute: 'version=1', suffix: '_final', expected: '1_final' },
2035+
{ versionAttribute: 'version="john\'s copy"', suffix: '_final', expected: 'john&apos;s copy_final' },
2036+
{ versionAttribute: 'version="final"', suffix: ' "working"', expected: 'final &quot;working&quot;' },
2037+
{ versionAttribute: 'version="emoji 🙃"', suffix: ' twice 🙃', expected: 'emoji &#x1f643; twice &#x1f643;' },
2038+
{ versionAttribute: `version="${arabicWord}"`, suffix: arabicWord, expected: '&#x635;&#x627;&#x62f;&#x642;&#x635;&#x627;&#x62f;&#x642;' },
2039+
{
2040+
versionAttribute: 'version="!@#$%^&amp;*()_+=-`~[]\\{}|&quot;:&apos;&lt;&gt;?/.,"',
2041+
suffix: '!@#$%^&*()_+=-`~[]\\{}|":\'<>?/.,',
2042+
expected: '!@#$%^&amp;*()_+=-`~[]\\{}|&quot;:&apos;&lt;&gt;?/.,!@#$%^&amp;*()_+=-`~[]\\{}|&quot;:&apos;&lt;&gt;?/.,'
2043+
},
2044+
/* eslint-enable no-multi-spaces, key-spacing */
2045+
].forEach(({ versionAttribute, suffix, expected }) => {
2046+
it(`should have ${expected} version`, () =>
2047+
addVersionSuffix(testData.forms.simple2.replace('version="2.1"', versionAttribute), suffix)
2048+
.then(result => {
2049+
// we always return version with "" delimiter
2050+
result.match(/(version="(.*)")/)[2].should.be.equal(expected);
2051+
})
2052+
);
2053+
});
2054+
2055+
2056+
it('should suffix an existing version attribute even if it uses single quote to delimit version text', () =>
2057+
addVersionSuffix(testData.forms.simple2.replace('version="2.1"', "version='2.1\"final\"'"), 'testtest')
2058+
.then(result => {
2059+
result.includes('version="2.1&quot;final&quot;testtest"').should.be.true();
2060+
})
2061+
);
2062+
2063+
it('should suffix an existing version attribute even if it uses single quote to delimit version text', () =>
2064+
addVersionSuffix(testData.forms.simple2.replace('version="2.1"', 'version=1.0'), '.1')
2065+
.then(result => {
2066+
result.includes('version="1.0.1"').should.be.true();
2067+
})
2068+
);
2069+
20232070
it('should suffix an existing namespaced version attribute', () =>
20242071
addVersionSuffix(testData.forms.simple2.replace('version', 'orx:version'), 'testtest').then((result) => result.should.equal(`<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:jr="http://openrosa.org/javarosa">
20252072
<h:head>
@@ -2156,6 +2203,44 @@ describe('form schema', () => {
21562203
</input>
21572204
</h:body>
21582205
</h:html>`)));
2206+
2207+
const arabicWord = 'صادق';
2208+
[
2209+
/* eslint-disable no-multi-spaces, key-spacing */
2210+
{ versionAttribute: '', newVersion: 'test', expected: 'test' },
2211+
{ versionAttribute: 'version=""', newVersion: 'test', expected: 'test' },
2212+
{ versionAttribute: 'version="first"', newVersion: '', expected: '' },
2213+
{ versionAttribute: 'version=\'first\'', newVersion: 'test', expected: 'test' },
2214+
{ versionAttribute: 'version="2.1"', newVersion: 'test', expected: 'test' },
2215+
{ versionAttribute: 'version = "final"', newVersion: '(working)', expected: '(working)' },
2216+
{ versionAttribute: 'version=1', newVersion: '1.0', expected: '1.0' },
2217+
{ versionAttribute: 'version=1.0', newVersion: '2.0', expected: '2.0' },
2218+
{ versionAttribute: 'version=1', newVersion: 'final', expected: 'final' },
2219+
{ versionAttribute: 'version="john\'s copy"', newVersion: 'jane\'s copy', expected: 'jane&apos;s copy' },
2220+
{ versionAttribute: 'version="final"', newVersion: '"working"', expected: '&quot;working&quot;' },
2221+
{ versionAttribute: 'version="emoji 🙃"', newVersion: '🙃 🙃', expected: '&#x1f643; &#x1f643;' },
2222+
{ versionAttribute: `version="sadiq"`, newVersion: arabicWord, expected: '&#x635;&#x627;&#x62f;&#x642;' },
2223+
{
2224+
// getodk/central#1470
2225+
versionAttribute: 'version="!@#$%^&amp;*()_+=-`~[]\\{}|&quot;:&apos;&lt;&gt;?/.,"',
2226+
newVersion: 'clean',
2227+
expected: 'clean'
2228+
},
2229+
{
2230+
versionAttribute: 'version="clean"',
2231+
newVersion: '!@#$%^&*()_+=-`~[]\\{}|":\'<>?/.,',
2232+
expected: '!@#$%^&amp;*()_+=-`~[]\\{}|&quot;:&apos;&lt;&gt;?/.,'
2233+
},
2234+
/* eslint-enable no-multi-spaces, key-spacing */
2235+
].forEach(({ versionAttribute, newVersion, expected }) => {
2236+
it(`should have ${expected} version`, () =>
2237+
setVersion(testData.forms.simple2.replace('version="2.1"', versionAttribute), newVersion)
2238+
.then(result => {
2239+
// we always return version with "" delimiter
2240+
result.match(/(version="(.*)")/)[2].should.be.equal(expected);
2241+
})
2242+
);
2243+
});
21592244
});
21602245

21612246
describe('updateEntityForm', () => {

0 commit comments

Comments
 (0)