diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f95d9c8a94..90a28c06da 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -629,6 +629,10 @@ jobs: run: |- set -o pipefail; yarn run ft_healthchecks | tee /tmp/artifacts/${{ matrix.job-name }}/ft_healthchecks.log + - name: Run scripts tests + run: |- + set -o pipefail; + yarn run ft_scripts | tee /tmp/artifacts/${{ matrix.job-name }}/ft_scripts.log - name: Teardown CI services run: docker compose down redis sproxyd metadata-standalone vault cloudserver-sse-before-migration working-directory: .github/docker diff --git a/bin/ensureServiceUser b/bin/ensureServiceUser index 8bcd2ef402..e33554a23c 100755 --- a/bin/ensureServiceUser +++ b/bin/ensureServiceUser @@ -16,6 +16,7 @@ const { PutUserPolicyCommand, ListAccessKeysCommand, CreateAccessKeyCommand, + NoSuchEntityException, } = require('@aws-sdk/client-iam'); const { version } = require('../package.json'); @@ -181,7 +182,7 @@ function collectResource(v, done) { v.collect() .then(res => done(null, res)) .catch((err) => { - if (err.name === 'NoSuchEntity') { + if (err instanceof NoSuchEntityException) { return done(); } diff --git a/package.json b/package.json index 36b94c5f94..64695e9864 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenko/cloudserver", - "version": "9.3.8", + "version": "9.3.9", "description": "Zenko CloudServer, an open-source Node.js implementation of a server handling the Amazon S3 protocol", "main": "index.js", "engines": { @@ -126,6 +126,7 @@ "ft_healthchecks": "cd tests/functional/healthchecks && yarn test", "ft_s3cmd": "cd tests/functional/s3cmd && mocha --reporter mocha-multi-reporters --reporter-options configFile=$INIT_CWD/tests/reporter-config.json -t 40000 *.js --exit", "ft_s3curl": "cd tests/functional/s3curl && mocha --reporter mocha-multi-reporters --reporter-options configFile=$INIT_CWD/tests/reporter-config.json -t 40000 *.js --exit", + "ft_scripts": "cd tests/functional/scripts && mocha --reporter mocha-multi-reporters --reporter-options configFile=$INIT_CWD/tests/reporter-config.json -t 40000 *.js --exit", "ft_util": "cd tests/functional/utilities && mocha --reporter mocha-multi-reporters --reporter-options configFile=$INIT_CWD/tests/reporter-config.json -t 40000 *.js --exit", "ft_test": "npm-run-all -s ft_awssdk ft_s3cmd ft_s3curl ft_node ft_healthchecks ft_management ft_util ft_backbeat", "ft_search": "cd tests/functional/aws-node-sdk && mocha --reporter mocha-multi-reporters --reporter-options configFile=$INIT_CWD/tests/reporter-config.json -t 90000 test/mdSearch --exit", diff --git a/tests/functional/scripts/ensureServiceUser.js b/tests/functional/scripts/ensureServiceUser.js new file mode 100644 index 0000000000..931523d396 --- /dev/null +++ b/tests/functional/scripts/ensureServiceUser.js @@ -0,0 +1,176 @@ +const assert = require('assert'); +const crypto = require('crypto'); +const path = require('path'); +const { execFile } = require('child_process'); +const { promisify } = require('util'); + +const { + IAMClient, + GetUserCommand, + GetUserPolicyCommand, + ListAccessKeysCommand, + DeleteUserCommand, + DeleteUserPolicyCommand, + DeleteAccessKeyCommand, + CreateUserCommand, + NoSuchEntityException, +} = require('@aws-sdk/client-iam'); + +const { getCredentials } = require('../aws-node-sdk/test/support/credentials'); + +const execFileAsync = promisify(execFile); + +const script = path.join(__dirname, '../../../bin/ensureServiceUser'); +const iamEndpoint = process.env.IAM_ENDPOINT || 'http://localhost:8600'; +const { accessKeyId, secretAccessKey } = getCredentials(); + +const systemPrefix = '/scality-internal/'; + +const iamClient = new IAMClient({ + endpoint: iamEndpoint, + region: 'us-east-1', + credentials: { + accessKeyId, + secretAccessKey, + }, +}); + +function randomUserName() { + return `ensure-service-user-test-${crypto.randomBytes(4).toString('hex')}`; +} + +function runScript(userName) { + return execFileAsync('node', [script, 'apply', userName, '--iam-endpoint', iamEndpoint], { + env: { + ...process.env, + AWS_ACCESS_KEY_ID: accessKeyId, + AWS_SECRET_ACCESS_KEY: secretAccessKey, + }, + }); +} + +async function ignoreNoSuchEntity(promise) { + try { + return await promise; + } catch (err) { + if (err instanceof NoSuchEntityException) { + return null; + } + throw err; + } +} + +// the cleanup runs whatever state the test left behind, so every +// deletion has to tolerate resources that were never created +async function deleteServiceUser(userName) { + const keys = await ignoreNoSuchEntity( + iamClient.send( + new ListAccessKeysCommand({ + UserName: userName, + MaxItems: 100, + }), + ), + ); + for (const key of keys ? keys.AccessKeyMetadata : []) { + await ignoreNoSuchEntity( + iamClient.send( + new DeleteAccessKeyCommand({ + UserName: userName, + AccessKeyId: key.AccessKeyId, + }), + ), + ); + } + await ignoreNoSuchEntity( + iamClient.send( + new DeleteUserPolicyCommand({ + UserName: userName, + PolicyName: userName, + }), + ), + ); + await ignoreNoSuchEntity( + iamClient.send( + new DeleteUserCommand({ + UserName: userName, + }), + ), + ); +} + +describe('ensureServiceUser script', () => { + let userName; + + beforeEach(() => { + userName = randomUserName(); + }); + + afterEach(async () => { + await deleteServiceUser(userName); + }); + + it('should create the service user, policy and access key when none exist', async () => { + const { stdout } = await runScript(userName); + + assert.notStrictEqual(stdout, ''); + assert.ok(!stdout.includes('"level":"error"'), `unexpected error in output: ${stdout}`); + + const result = JSON.parse(stdout); + assert.strictEqual(result.message, 'success'); + assert.ok(result.data.AccessKeyId); + assert.ok(result.data.SecretAccessKey); + assert.strictEqual(result.data.UserName, userName); + + const user = await iamClient.send(new GetUserCommand({ UserName: userName })); + assert.strictEqual(user.User.Path, systemPrefix); + + const policy = await iamClient.send( + new GetUserPolicyCommand({ + UserName: userName, + PolicyName: userName, + }), + ); + const policyDocument = JSON.parse(decodeURIComponent(policy.PolicyDocument)); + assert.strictEqual(policyDocument.Statement[0].Sid, 'RateLimitAdminAPIs'); + + const keys = await iamClient.send( + new ListAccessKeysCommand({ + UserName: userName, + MaxItems: 100, + }), + ); + assert.strictEqual(keys.AccessKeyMetadata.length, 1); + assert.strictEqual(keys.AccessKeyMetadata[0].AccessKeyId, result.data.AccessKeyId); + }); + + it('should succeed without creating a new access key when the user already exists', async () => { + const first = JSON.parse((await runScript(userName)).stdout); + const { stdout } = await runScript(userName); + + assert.ok(!stdout.includes('"level":"error"'), `unexpected error in output: ${stdout}`); + + const result = JSON.parse(stdout); + assert.strictEqual(result.message, 'success'); + // on re-run the script reports the existing key metadata instead of creating one + assert.strictEqual(result.data.length, 1); + assert.strictEqual(result.data[0].AccessKeyId, first.data.AccessKeyId); + + const keys = await iamClient.send( + new ListAccessKeysCommand({ + UserName: userName, + MaxItems: 100, + }), + ); + assert.strictEqual(keys.AccessKeyMetadata.length, 1); + }); + + it('should fail when the user exists outside the scality-internal path', async () => { + await iamClient.send(new CreateUserCommand({ UserName: userName })); + + await assert.rejects(runScript(userName), err => { + assert.strictEqual(err.code, 1); + assert.match(err.stdout, /EntityAlreadyExists/); + return true; + }); + }); +});