diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a0a353384..8dee5e55c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,8 +22,9 @@ jobs: node-version: ${{ matrix.node-version }} - name: Install pyxform run: pip install git+https://github.com/medic/pyxform.git@medic-conf-1.17#egg=pyxform-medic - - name: npm ci and test - run: npm ci && npm test + - run: npm ci + - run: npm run eslint + - run: npm test - name: Archive Results uses: actions/upload-artifact@v4 with: diff --git a/package.json b/package.json index dddba61a6..c06ae5d57 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,7 @@ "scripts": { "clean": "rm -rf ./build/", "eslint": "eslint 'src/**/*.js' test/*.js 'test/**/*.js'", - "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", - "docker-stop-couchdb": "docker stop cht-conf-couchdb || true", - "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\" --exclude \"../../test/e2e/**/*.spec.js\" && cd ../.. && npm run docker-stop-couchdb", + "test": "npm run clean && mkdir -p build/test && cp -r test/data build/test/data && cd build/test && nyc --reporter=html mocha \"../../test/**/*.spec.js\" --exclude \"../../test/e2e/**/*.spec.js\"", "test-e2e": "mocha --config test/e2e/.mocharc.js", "semantic-release": "semantic-release" }, diff --git a/test/e2e/.mocharc.js b/test/e2e/.mocharc.js index c20016ad4..35d8e630d 100644 --- a/test/e2e/.mocharc.js +++ b/test/e2e/.mocharc.js @@ -1,3 +1,7 @@ +const chaiAsPromised = require('chai-as-promised'); +const chai = require('chai'); +chai.use(chaiAsPromised); + module.exports = { allowUncaught: false, color: true, diff --git a/test/e2e/cht-conf-utils.js b/test/e2e/cht-conf-utils.js index 82802e706..9a320f06a 100644 --- a/test/e2e/cht-conf-utils.js +++ b/test/e2e/cht-conf-utils.js @@ -4,33 +4,43 @@ const fs = require('fs'); const fse = require('fs-extra'); const log = require('../../src/lib/log'); -const { getProjectUrl } = require('./cht-docker-utils'); +const { getProjectUrl, DEFAULT_PROJECT_NAME } = require('./cht-docker-utils'); -const getProjectDirectory = (projectName) => path.resolve(__dirname, `../../build/${projectName}`); +const getProjectDirectory = (projectName = DEFAULT_PROJECT_NAME) => path.resolve(__dirname, `../../build/${projectName}`); -const runChtConf = (projectName, command) => new Promise((resolve, reject) => { - getProjectUrl(projectName).then(url => { - const projectDirectory = getProjectDirectory(projectName); - const cliPath = path.join(__dirname, '../../src/bin/index.js'); - exec(`node ${cliPath} --url=${url} ${command}`, { cwd: projectDirectory }, (error, stdout, stderr) => { - if (!error) { - return resolve(stdout); - } +const runChtConf = async ( + command, + { url, sessionToken } = {}, + projectName = DEFAULT_PROJECT_NAME +) => { + const instanceUrl = url || await getProjectUrl(projectName); + const sessionParam = sessionToken ? `--session-token=${sessionToken}` : ''; + const projectDirectory = getProjectDirectory(projectName); + const cliPath = path.join(__dirname, '../../src/bin/index.js'); + return new Promise((resolve, reject) => { + exec( + `node ${cliPath} --url=${instanceUrl} ${sessionParam} ${command}`, + { cwd: projectDirectory }, + (error, stdout, stderr) => { + if (!error) { + return resolve(stdout); + } - log.error(stderr); - reject(new Error(stdout.toString('utf8'))); - }); + log.error(stderr); + reject(new Error(stdout.toString('utf8'))); + } + ); }); -}); +}; -const cleanupProject = (projectName) => { +const cleanupProject = (projectName = DEFAULT_PROJECT_NAME) => { const projectDirectory = getProjectDirectory(projectName); if (fs.existsSync(projectDirectory)) { fse.removeSync(projectDirectory); } }; -const initProject = async (projectName) => { +const initProject = async (projectName = DEFAULT_PROJECT_NAME) => { const projectDirectory = getProjectDirectory(projectName); cleanupProject(projectName); @@ -46,10 +56,10 @@ const initProject = async (projectName) => { }, null, 4), ); - await runChtConf(projectName, 'initialise-project-layout'); + await runChtConf('initialise-project-layout', projectName); }; -const writeBaseAppSettings = async (projectName, baseSettings) => { +const writeBaseAppSettings = async (baseSettings, projectName = DEFAULT_PROJECT_NAME) => { const projectDirectory = getProjectDirectory(projectName); return await fs.promises.writeFile( @@ -58,7 +68,7 @@ const writeBaseAppSettings = async (projectName, baseSettings) => { ); }; -const readCompiledAppSettings = async (projectName) => { +const readCompiledAppSettings = async (projectName = DEFAULT_PROJECT_NAME) => { const projectDirectory = getProjectDirectory(projectName); return JSON.parse( diff --git a/test/e2e/edit-app-settings.spec.js b/test/e2e/edit-app-settings.spec.js index c5f56349d..fc5ee83bb 100644 --- a/test/e2e/edit-app-settings.spec.js +++ b/test/e2e/edit-app-settings.spec.js @@ -1,7 +1,7 @@ const { expect } = require('chai'); const request = require('request-promise-native'); -const { DEFAULT_PROJECT_NAME, getProjectUrl } = require('./cht-docker-utils'); +const { getProjectUrl } = require('./cht-docker-utils'); const { cleanupProject, initProject, @@ -11,19 +11,13 @@ const { } = require('./cht-conf-utils'); describe('edit-app-settings', () => { - const projectName = DEFAULT_PROJECT_NAME; const findLanguage = (settingsLanguages, locale) => settingsLanguages.find(language => language.locale === locale); - before(async () => { - await initProject(projectName); - }); - - after(async () => { - await cleanupProject(projectName); - }); + before(initProject); + after(cleanupProject); it('disables a language, recompile, and push app settings', async () => { - const url = await getProjectUrl(projectName); + const url = await getProjectUrl(); const baseSettings = await request.get({ url: `${url}/api/v1/settings`, json: true }); expect(findLanguage(baseSettings.languages, 'en').enabled).to.be.true; expect(findLanguage(baseSettings.languages, 'fr').enabled).to.be.true; @@ -39,10 +33,10 @@ describe('edit-app-settings', () => { }); baseSettings.locale = 'fr'; baseSettings.locale_outgoing = 'fr'; - await writeBaseAppSettings(projectName, baseSettings); + await writeBaseAppSettings(baseSettings); - await runChtConf(projectName, 'compile-app-settings'); - const compiledSettings = await readCompiledAppSettings(projectName); + await runChtConf('compile-app-settings'); + const compiledSettings = await readCompiledAppSettings(); expect(compiledSettings.languages.find(language => language.locale === 'en')).to.deep.equal({ locale: 'en', enabled: false, @@ -50,7 +44,7 @@ describe('edit-app-settings', () => { expect(compiledSettings.locale).to.equal('fr'); expect(compiledSettings.locale_outgoing).to.equal('fr'); - await runChtConf(projectName, 'upload-app-settings'); + await runChtConf('upload-app-settings'); const newSettings = await request.get({ url: `${url}/api/v1/settings`, json: true }); expect(newSettings.languages.find(language => language.locale === 'en')).to.deep.equal({ locale: 'en', diff --git a/test/e2e/session-token.spec.js b/test/e2e/session-token.spec.js new file mode 100644 index 000000000..1802f3084 --- /dev/null +++ b/test/e2e/session-token.spec.js @@ -0,0 +1,97 @@ +const { expect } = require('chai'); +const rpn = require('request-promise-native'); +const fs = require('fs-extra'); +const path = require('path'); +const PouchDB = require('pouchdb-core'); +const { getProjectUrl } = require('./cht-docker-utils'); +const { + cleanupProject, + initProject, + getProjectDirectory, runChtConf, +} = require('./cht-conf-utils'); + +const COUCH_URL_PATTERN = /^(https?:\/\/)([^:]+):([^@]+)@(.*)$/; + +const projectPath = getProjectDirectory(); + +const createProjectPath = async () => { + await initProject(); + + const docs = [ + { _id: 'one', name: 'Document One' }, + { _id: 'two', name: 'Document Two' }, + ]; + + const jsonDocsPath = `${projectPath}/json_docs`; + fs.ensureDirSync(`${projectPath}/json_docs`); + + docs.forEach(doc => { + fs.writeJsonSync(path.join(jsonDocsPath, `${doc._id}.doc.json`), doc); + }); +}; + +describe('integration/session-token', () => { + let authenticatedUrl; + let unauthenticatedUrl; + let sessionToken; + const action = 'upload-docs --force'; + + const initializeDatabase = () => { + PouchDB.plugin(require('pouchdb-adapter-http')); + PouchDB.plugin(require('pouchdb-mapreduce')); + }; + + const getSessionToken = async (name, password) => { + const options = { + method: 'POST', + uri: `${unauthenticatedUrl}/_session`, + body: { + name, + password, + }, + resolveWithFullResponse: true, + json: true + }; + + try { + const response = await rpn(options); + const setCookieHeader = response.headers['set-cookie']; + // Extract the session token from the set-cookie header + const sessionCookie = setCookieHeader.find(cookie => cookie.startsWith('AuthSession=')); + return sessionCookie.split(';')[0].split('=')[1]; + } catch (error) { + throw new Error(`Failed to get session token: ${error.message}`); + } + }; + + before(async () => { + await createProjectPath(); + initializeDatabase(); + authenticatedUrl = await getProjectUrl(); + const [, prefix, user, password, rootUrl] = authenticatedUrl.match(COUCH_URL_PATTERN); + unauthenticatedUrl = `${prefix}${rootUrl}`; + sessionToken = await getSessionToken(user, password); + }); + + after(cleanupProject); + + it('should handle authentication with session token', async () => { + const stdout = await runChtConf(action, { url: unauthenticatedUrl, sessionToken }); + expect(stdout).to.contain('INFO upload-docs complete.'); + }); + + it('should handle authentication with basic authentication', async () => { + const stdout = await runChtConf(action, { url: authenticatedUrl }); + expect(stdout).to.contain('INFO upload-docs complete.'); + }); + + it('should fail with incorrect session token', async () => { + const incorrectToken = 'incorrect-token'; + const promiseToExecute = runChtConf(action, { url: unauthenticatedUrl, sessionToken: incorrectToken }); + await expect(promiseToExecute) + .to.be.rejected + .and.eventually.have.property('message') + // Bad Request: Malformed AuthSession cookie + .that.contains('INFO Error: Received error code 400'); + }); +}); diff --git a/test/integration/session-token.spec.js b/test/integration/session-token.spec.js deleted file mode 100644 index 52eafb667..000000000 --- a/test/integration/session-token.spec.js +++ /dev/null @@ -1,152 +0,0 @@ -/* eslint-disable no-console */ -const chai = require('chai'); -const { exec } = require('child_process'); -const rpn = require('request-promise-native'); -const fs = require('fs-extra'); -const path = require('path'); -const PouchDB = require('pouchdb-core'); - -const { expect } = chai; -chai.use(require('chai-as-promised')); - -const COUCHDB_USERNAME = 'admin'; -const COUCHDB_PASSWORD = 'password'; -const COUCHDB_URL = 'http://localhost:6984'; - -const projectPath = path.join(__dirname, '../test_project'); - -const createProjectPath = () => { - fs.ensureDirSync(projectPath); - fs.writeJsonSync(path.join(projectPath, 'package.json'), { - name: 'test-project', - version: '1.0.0', - dependencies: { - 'cht-conf': 'latest' - } - }); - - const docs = [ - { _id: 'one', name: 'Document One' }, - { _id: 'two', name: 'Document Two' }, - ]; - - const jsonDocsPath = `${projectPath}/json_docs`; - fs.ensureDirSync(`${projectPath}/json_docs`); - - docs.forEach(doc => { - fs.writeJsonSync(path.join(jsonDocsPath, `${doc._id}.doc.json`), doc); - }); -}; - -const cleanProjectPath = () => { - fs.removeSync(projectPath); -}; - -const runCliCommand = (command) => { - return new Promise((resolve, reject) => { - const cliPath = path.join(__dirname, '../../src/bin/index.js'); - exec(`node ${cliPath} ${command}`, { cwd: projectPath }, (error, stdout, stderr) => { - if (error) { - console.error(stderr); - reject(new Error(stdout)); - } else { - resolve(stdout); - } - }); - }); -}; - -describe('integration/session-token', function() { - this.timeout(15000); - - let sessionToken; - const action = 'upload-docs --force'; - - const initializeProject = async () => { - await runCliCommand('initialise-project-layout'); - }; - - const initializeDatabase = async () => { - PouchDB.plugin(require('pouchdb-adapter-http')); - PouchDB.plugin(require('pouchdb-mapreduce')); - const pouchDb = new PouchDB(`${COUCHDB_URL}/medic`); - - await pouchDb.put({ - _id: '_design/medic', - views: {} - }); - }; - - const createAdminUser = async () => { - const pouchDb = new PouchDB(`${COUCHDB_URL}/_users`); - const userDoc = { - _id: `org.couchdb.user:${COUCHDB_USERNAME}`, - name: COUCHDB_USERNAME, - roles: ['_admin'], - type: 'user', - password: COUCHDB_PASSWORD - }; - await pouchDb.put(userDoc); - }; - - const getSessionToken = async () => { - const options = { - method: 'POST', - uri: 'http://localhost:6984/_session', - body: { - name: COUCHDB_USERNAME, - password: COUCHDB_PASSWORD, - }, - resolveWithFullResponse: true, - json: true - }; - - try { - const response = await rpn(options); - const setCookieHeader = response.headers['set-cookie']; - // Extract the session token from the set-cookie header - const sessionCookie = setCookieHeader.find(cookie => cookie.startsWith('AuthSession=')); - const sessionToken = sessionCookie.split(';')[0].split('=')[1]; - return sessionToken; - } catch (error) { - throw new Error(`Failed to get session token: ${error.message}`); - } - }; - - before(async () => { - createProjectPath(); - await initializeDatabase(); - await initializeProject(); - await createAdminUser(); - sessionToken = await getSessionToken(); - }); - - after(cleanProjectPath); - - it('should handle authentication with session token', async () => { - const stdout = await runCliCommand( - `--url=${COUCHDB_URL} --session-token=${sessionToken} ${action}` - ); - expect(stdout).to.contain('INFO upload-docs complete.'); - }); - - it('should handle authentication with basic authentication', async () => { - const url = `http://${COUCHDB_USERNAME}:${COUCHDB_PASSWORD}@localhost:6984`; - const stdout = await runCliCommand( - `--url=${url} ${action}` - ); - expect(stdout).to.contain('INFO upload-docs complete.'); - }); - - it('should fail with incorrect session token', async () => { - const incorrectToken = 'incorrect-token'; - const promiseToExecute = runCliCommand( - `--url=${COUCHDB_URL} --session-token=${incorrectToken} ${action}` - ); - await expect(promiseToExecute) - .to.be.rejected - .and.eventually.have.property('message') - // Bad Request: Malformed AuthSession cookie - .that.contains('INFO Error: Received error code 400'); - }); -}); diff --git a/test/scripts/wait_for_response_code.sh b/test/scripts/wait_for_response_code.sh deleted file mode 100644 index 4e75870e7..000000000 --- a/test/scripts/wait_for_response_code.sh +++ /dev/null @@ -1,8 +0,0 @@ -count=0 -echo 'Starting curl check' -while [ `curl -o /dev/null -s -w "%{http_code}\n" http://localhost:$1` -ne "$2" -a $count -lt 300 ] - do count=$((count+=1)) - echo "waiting for $3 to respond with 200 status code. Current count is $count" - sleep 1 -done -echo 'Ended curl check'