Skip to content

Commit

Permalink
Backup Cognito user pool and copy it to S3 (#2)
Browse files Browse the repository at this point in the history
Backup Cognito user pool and copy it to S3
  • Loading branch information
mjgiarlo authored May 3, 2019
2 parents 737a6c1 + a9b3f26 commit 5555f8b
Show file tree
Hide file tree
Showing 25 changed files with 7,445 additions and 1 deletion.
13 changes: 13 additions & 0 deletions .babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"plugins": [["babel-plugin-dotenv", {
"replacedModuleName": "babel-dotenv"
}]],
"presets": [
"@babel/preset-env"
],
"env": {
"test": {
"plugins": ["@babel/plugin-transform-runtime"]
}
}
}
72 changes: 72 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Javascript Node CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-javascript/ for more details
#

defaults: &defaults
docker:
- image: circleci/node:10.15.3
working_directory: ~/sinopia_user_backup

version: 2
jobs:
build:
<<: *defaults
steps:
- checkout
- setup_remote_docker
- restore_cache:
keys:
- dependencies-{{ checksum "package.json" }}
# fallback to using the latest cache if no exact match is found
- dependencies-
- run:
name: Install dependencies
command: npm install
- save_cache:
paths:
- node_modules
key: dependencies-{{ checksum "package.json" }}
- run:
name: Run linter
command: npm run lint
- run:
name: Setup Code Climate test-reporter
command: |
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
chmod +x ./cc-test-reporter
- run:
name: Run unit tests and send coverage results
command: |
./cc-test-reporter before-build
npm run ci
./cc-test-reporter after-build --exit-code $?
register_image:
<<: *defaults
steps:
- checkout
- setup_remote_docker
- restore_cache:
key: dependencies-{{ checksum "package.json" }}
- attach_workspace:
at: .
- run:
name: Build & push docker image
# NOTE: the env variables holding docker credentials are stored in the CircleCI dashboard
command: |
docker build -t ld4p/sinopia_user_backup:latest .
echo $DOCKER_PASS | docker login -u $DOCKER_USER --password-stdin
docker push ld4p/sinopia_user_backup:latest
workflows:
version: 2
build:
jobs:
- build
- register_image:
requires:
- build
filters:
branches:
only:
- master
22 changes: 22 additions & 0 deletions .codeclimate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
version: "2"

exclude_patterns:
- "__tests__/"
- "__mocks__/"
- 'eslintrc.js'
- 'jest.config.js'
- '**/node_modules/'

plugins:
eslint:
enabled: true
config:
config: .eslintrc.js
channel: "eslint-5"
duplication:
enabled: true
config:
languages:
- javascript:
nodesecurity:
enabled: true
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.dockerignore
.env
.git
.node-version
Dockerfile
__mocks__
__tests__
node_modules
npm-debug.log
40 changes: 40 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
module.exports = {
plugins: [
"import",
"jest",
"security"
],
extends: [
"eslint:recommended",
"plugin:node/recommended",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:security/recommended",
"plugin:jest/recommended"
],
env: {
"es6": true,
"jest": true,
"node": true
},
parser: "babel-eslint",
parserOptions: {
ecmaVersion: 2019,
sourceType: "module"
},
overrides: [
{
"files": ["**/*.js"],
"rules": {
// See https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/es-syntax.md
// rule supposedly matches ECMA version with node
// we get: "Import and export declarations are not supported yet"
"node/no-unsupported-features/es-syntax": "off",
// Avoiding: "warning Found fs.readFileSync with non literal argument ..."
"security/detect-non-literal-fs-filename": "off",
// this is a CLI tool; we DO want to send output to console
"no-console": "off",
}
}
]
}
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM circleci/node:10.15

WORKDIR /home/circleci

COPY . .

RUN npm install

CMD ["bin/backup"]
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,45 @@
[![CircleCI](https://circleci.com/gh/LD4P/sinopia_user_backup.svg?style=svg)](https://circleci.com/gh/LD4P/sinopia_user_backup)
[![Code Climate](https://codeclimate.com/github/LD4P/sinopia_user_backup/badges/gpa.svg)](https://codeclimate.com/github/LD4P/sinopia_user_backup)
[![Code Climate Test Coverage](https://codeclimate.com/github/LD4P/sinopia_user_backup/badges/coverage.svg)](https://codeclimate.com/github/LD4P/sinopia_user_backup/coverage)

# sinopia_user_backup
A Node component for backing up the Cognito user pool for Sinopia

A Node application that backs up AWS Cognito user pools to AWS S3. The application stores backups in S3 with the following structure: `{DATESTAMP}/user-backup_{TIMESTAMP}_{USER_POOL_ID}.json`

Requires configuration via environment variables:

* `AWS_ACCESS_KEY_ID`: The AWS access key associated with an account that has access to both Cognito and S3
* `AWS_SECRET_ACCESS_KEY`: The AWS access secret associated with an account that has access to both Cognito and S3
* `AWS_REGION`: The AWS region Cognito and S3 are running within
* `S3_BUCKET`: The S3 bucket into which user backup data should be copied
* `COGNITO_USER_POOL_ID`: The Cognito user pool to backup

## Testing

A `docker-compose` configuration is included to mimic how the app would be run in a container-based production environment. To run the app, use:

```shell
# Add -d flag to run in the background
$ docker-compose up
```

### Run the linter

```shell
$ npm run lint
```

### Run unit tests

```shell
$ npm test
```

## Build and push image

The CircleCI build is configured to perform these steps automatically on any successful build on the `master` branch. If you need to manually build and push an image, you can do this:

```shell
$ docker build -t ld4p/sinopia_user_backup:latest .
$ docker push ld4p/sinopia_user_backup:latest
```
19 changes: 19 additions & 0 deletions __mocks__/cognito-fake.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Yes, this is gross, but this is the interface of the dependency we're faking.
export default class CognitoFake {
listUsers() {
return {
promise: async () => {
return new Promise(resolve => {
return resolve(
{
Users: [
{ Username: 'user1' },
{ Username: 'user2' }
]
}
)
})
}
}
}
}
37 changes: 37 additions & 0 deletions __mocks__/cognito-pagination-fake.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Yes, this is gross, but this is the interface of the dependency we're faking.

export default class CognitoPaginationFake {
constructor() {
// This attr makes sure we can split a result set into paginated chunks
// without endless recursion.
this.calledAlready = false
}

listUsers() {
return {
promise: async () => {
return new Promise(resolve => {
if (this.calledAlready) {
return resolve(
{
Users: [
{ Username: 'user2' }
]
}
)
} else {
this.calledAlready= true
return resolve(
{
Users: [
{ Username: 'user1' }
],
PaginationToken: 'token'
}
)
}
})
}
}
}
}
5 changes: 5 additions & 0 deletions __mocks__/s3-error-fake.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default class S3ErrorFake {
putObject(_, callback) {
callback('uh oh')
}
}
5 changes: 5 additions & 0 deletions __mocks__/s3-success-fake.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default class S3SuccessFake {
putObject(_, callback) {
callback()
}
}
68 changes: 68 additions & 0 deletions __tests__/CopyUsers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import AWS from 'aws-sdk'
import CopyUsers from '../src/CopyUsers'
import S3ErrorFake from '../__mocks__/s3-error-fake'
import S3SuccessFake from '../__mocks__/s3-success-fake'

describe('CopyUsers', () => {
const userList = [
{ Username: 'user1' },
{ Username: 'user2' },
]
const copier = new CopyUsers(userList)

describe('constructor()', () => {
it('sets userListString', () => {
expect(copier.userListString).toEqual("[{\"Username\":\"user1\"},{\"Username\":\"user2\"}]")
})
it('sets s3', () => {
expect(copier.s3).toBeInstanceOf(AWS.S3)
})
it('sets objectKey', () => {
// Matches: {ISO DATE}/{AWS_REGION}_{USER_POOL_ID}.json
expect(copier.objectKey).toMatch(/\d{4}-\d{2}-\d{2}\/user-backup_\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z_my-region-1_uSeRPoOlId\.json/)
})
})

describe('copy()', () => {
const logSpy = jest.spyOn(console, 'log')
const errorSpy = jest.spyOn(console, 'error')

describe('when successful', () => {
beforeAll(() => {
copier.s3 = new S3SuccessFake()
})

it('calls putObject() on this.s3 and logs a non-error message', () => {
const s3Spy = jest.spyOn(copier.s3, 'putObject')
copier.copy()
expect(s3Spy).toHaveBeenCalledWith({
Body: "[{\"Username\":\"user1\"},{\"Username\":\"user2\"}]",
Bucket: 'my-bucket-name',
// The key value includes an ISO representation of a timestamp, so
// without gnarly date/time mocking, this is the best I could do here.
Key: expect.any(String)
}, expect.any(Function))
expect(logSpy).toHaveBeenCalledWith('Users backed up to S3')
expect(errorSpy).not.toHaveBeenCalled()
})
})

describe('when it errors out', () => {
beforeAll(() => {
copier.s3 = new S3ErrorFake()
})

it('calls putObject() on this.s3 and logs an error', () => {
const s3Spy = jest.spyOn(copier.s3, 'putObject')
copier.copy()
expect(s3Spy).toHaveBeenCalledWith({
Body: "[{\"Username\":\"user1\"},{\"Username\":\"user2\"}]",
Bucket: 'my-bucket-name',
Key: expect.any(String)
}, expect.any(Function))
expect(logSpy).not.toHaveBeenCalled()
expect(errorSpy).toHaveBeenCalledWith('error copying backup to S3: uh oh')
})
})
})
})
Loading

0 comments on commit 5555f8b

Please sign in to comment.