├── .circleci └── config.yml ├── .github └── .kodiak.toml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── frontend ├── .env ├── .env.example.local ├── .gitignore ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── serverless-single-page-app-plugin │ ├── index.js │ └── package.json ├── serverless.yml ├── src │ ├── App.test.tsx │ ├── App.tsx │ ├── __mocks__ │ │ └── auth0-js.ts │ ├── __snapshots__ │ │ └── App.test.tsx.snap │ ├── actions │ │ ├── api.test.ts │ │ ├── api.ts │ │ ├── auth.test.ts │ │ ├── auth.ts │ │ └── types.ts │ ├── components │ │ ├── Api │ │ │ ├── DbApi.test.tsx │ │ │ ├── DbApi.tsx │ │ │ ├── EmailApi.test.tsx │ │ │ ├── EmailApi.tsx │ │ │ ├── FileApi.test.tsx │ │ │ ├── FileApi.tsx │ │ │ ├── PrivateApi.test.tsx │ │ │ ├── PrivateApi.tsx │ │ │ ├── PublicApi.test.tsx │ │ │ ├── PublicApi.tsx │ │ │ ├── __snapshots__ │ │ │ │ ├── DbApi.test.tsx.snap │ │ │ │ ├── EmailApi.test.tsx.snap │ │ │ │ ├── FileApi.test.tsx.snap │ │ │ │ ├── PrivateApi.test.tsx.snap │ │ │ │ ├── PublicApi.test.tsx.snap │ │ │ │ └── index.test.tsx.snap │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── AuthStatus │ │ │ ├── __snapshots__ │ │ │ │ └── index.test.tsx.snap │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── Callback │ │ │ ├── __snapshots__ │ │ │ │ └── index.test.tsx.snap │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── Home │ │ │ ├── __snapshots__ │ │ │ │ └── index.test.tsx.snap │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── Loading │ │ │ ├── __snapshots__ │ │ │ │ └── index.test.tsx.snap │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── Login │ │ │ ├── __snapshots__ │ │ │ │ └── index.test.tsx.snap │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── NotFound │ │ │ ├── __snapshots__ │ │ │ │ └── index.test.tsx.snap │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── Private │ │ │ ├── __snapshots__ │ │ │ │ └── index.test.tsx.snap │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ └── Public │ │ │ ├── __snapshots__ │ │ │ └── index.test.tsx.snap │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ ├── index.css │ ├── index.tsx │ ├── lib │ │ ├── api.test.ts │ │ ├── api.ts │ │ ├── auth.test.ts │ │ └── auth.ts │ ├── react-app-env.d.ts │ ├── reducers │ │ ├── api.test.ts │ │ ├── api.ts │ │ ├── auth.test.ts │ │ ├── auth.ts │ │ └── index.ts │ ├── registerServiceWorker.ts │ ├── routes │ │ ├── index.test.tsx │ │ └── index.tsx │ ├── sagas │ │ ├── api.test.ts │ │ ├── api.ts │ │ ├── auth.test.ts │ │ ├── auth.ts │ │ ├── index.test.ts │ │ └── index.ts │ ├── selectors │ │ ├── api.test.ts │ │ ├── api.ts │ │ ├── auth.test.ts │ │ ├── auth.ts │ │ ├── router.test.ts │ │ └── router.ts │ ├── setupTests.ts │ └── types │ │ └── redux.ts ├── tsconfig.json └── tslint.json ├── lerna.json ├── package.json ├── renovate.json ├── scripts └── stackOutput.js ├── services ├── api-service │ ├── .gitignore │ ├── README.md │ ├── e2e │ │ ├── config.example.ts │ │ ├── privateEndpoint.test.ts │ │ ├── publicEndpoint.chai.test.ts │ │ ├── publicEndpoint.test.ts │ │ ├── setup.ts │ │ ├── setupFrameworks.ts │ │ ├── setup_hook.js │ │ └── utils │ │ │ ├── auth0.ts │ │ │ └── puppeteer.ts │ ├── jest.config.e2e.js │ ├── jest.config.js │ ├── package.json │ ├── serverless.yml │ ├── src │ │ ├── authHandler.test.ts │ │ ├── authHandler.ts │ │ ├── privateEndpointHandler.test.ts │ │ ├── privateEndpointHandler.ts │ │ ├── publicEndpointHandler.test.ts │ │ └── publicEndpointHandler.ts │ └── webpack.config.js ├── common │ ├── .gitignore │ ├── README.md │ ├── environment │ │ ├── config.example.json │ │ └── public_key.example.pem │ ├── jest.config.js │ ├── package.json │ └── src │ │ └── auth │ │ ├── authenticator.test.ts │ │ └── authenticator.ts ├── db-service │ ├── .gitignore │ ├── README.md │ ├── e2e │ │ ├── db.chai.test.ts │ │ ├── db.test.ts │ │ ├── setup.ts │ │ ├── setupFrameworks.ts │ │ └── setup_hook.js │ ├── jest.config.e2e.js │ ├── jest.config.js │ ├── package.json │ ├── serverless.yml │ ├── src │ │ ├── create.test.ts │ │ ├── create.ts │ │ ├── db.test.ts │ │ └── db.ts │ └── webpack.config.js ├── email-service │ ├── .gitignore │ ├── README.md │ ├── config.example.json │ ├── jest.config.js │ ├── package.json │ ├── send-email-data.json │ ├── serverless.yml │ ├── src │ │ ├── authHandler.test.ts │ │ ├── authHandler.ts │ │ ├── handler.test.ts │ │ ├── handler.ts │ │ ├── mailSender.test.ts │ │ └── mailSender.ts │ └── webpack.config.js ├── file-service │ ├── .gitignore │ ├── README.md │ ├── e2e │ │ ├── handler.chai.test.ts │ │ ├── handler.test.ts │ │ ├── setup.ts │ │ ├── setupFrameworks.ts │ │ └── setup_hook.js │ ├── jest.config.e2e.js │ ├── jest.config.js │ ├── package.json │ ├── serverless.yml │ ├── src │ │ ├── fileSaver.test.ts │ │ ├── fileSaver.ts │ │ ├── handler.test.ts │ │ └── handler.ts │ └── webpack.config.js ├── kinesis-service │ ├── .gitignore │ ├── README.md │ ├── e2e │ │ ├── handler.chai.test.ts │ │ ├── handler.test.ts │ │ ├── setup.ts │ │ ├── setupFrameworks.ts │ │ └── setup_hook.js │ ├── jest.config.e2e.js │ ├── jest.config.js │ ├── package.json │ ├── serverless.yml │ ├── src │ │ ├── handler.test.ts │ │ ├── handler.ts │ │ ├── kinesis.test.ts │ │ └── kinesis.ts │ └── webpack.config.js ├── tsconfig.json └── tslint.json └── yarn.lock /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [merge.automerge_dependencies] 4 | versions = ["minor", "patch"] 5 | usernames = ["renovate"] 6 | 7 | [approve] 8 | auto_approve_usernames = ["renovate"] 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | 4 | #junit results 5 | reports 6 | 7 | lerna-debug.log -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "useTabs": false, 8 | "overrides": [ 9 | { 10 | "files": "*.json", 11 | "options": { "printWidth": 200 } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018, Erez Rokah. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | BROWSER=none -------------------------------------------------------------------------------- /frontend/.env.example.local: -------------------------------------------------------------------------------- 1 | REACT_APP_AUTH0_AUDIENCE=REPLACE_ME 2 | REACT_APP_AUTH0_CLIENT_ID=REPLACE_ME 3 | REACT_APP_AUTH0_DOMAIM=REPLACE_ME 4 | REACT_APP_AUTH0_REDIRECT_URI=http://localhost:3000/callback -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | .serverless 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "auth0-js": "^9.7.3", 7 | "connected-react-router": "^6.0.0", 8 | "react": "^17.0.0", 9 | "react-dom": "^17.0.0", 10 | "react-loadable": "^5.5.0", 11 | "react-redux": "^7.0.0", 12 | "react-router-dom": "^6.0.0", 13 | "react-scripts": "^5.0.0", 14 | "redux": "^4.0.0", 15 | "redux-actions": "^2.6.1", 16 | "redux-auth-wrapper": "^3.0.0", 17 | "redux-ignore": "^1.2.5", 18 | "redux-logger": "^3.0.6", 19 | "redux-saga": "^1.0.0", 20 | "reselect": "^4.0.0", 21 | "semantic-ui-css": "^2.4.0", 22 | "semantic-ui-react": "^2.0.0", 23 | "styled-components": "^5.0.0" 24 | }, 25 | "scripts": { 26 | "analyze": "source-map-explorer build/static/js/main.*", 27 | "start": "react-scripts start", 28 | "build": "CI=true react-scripts build", 29 | "lint": "tslint '*.ts'", 30 | "test": "TZ=GMT CI=true react-scripts test --env=jsdom", 31 | "test:watch": "TZ=GMT react-scripts test --env=jsdom", 32 | "test:ci": "TZ=GMT CI=true JEST_JUNIT_OUTPUT=../reports/junit/${npm_package_name}-test-results.xml yarn test --runInBand --ci --reporters=jest-junit --reporters=default", 33 | "coverage": "TZ=GMT yarn test --coverage", 34 | "eject": "react-scripts eject", 35 | "deploy": "serverless deploy", 36 | "remove": "serverless remove", 37 | "publish": "serverless publishSite", 38 | "prettier": "prettier --write src/**/*.ts src/**/*.tsx serverless-single-page-app-plugin/*.js", 39 | "prettier:ci": "prettier --list-different src/**/*.ts src/**/*.tsx serverless-single-page-app-plugin/*.js" 40 | }, 41 | "devDependencies": { 42 | "@anttiviljami/serverless-stack-output": "^0.3.1", 43 | "@types/auth0-js": "^9.13.4", 44 | "@types/enzyme": "^3.10.5", 45 | "@types/jest": "^27.0.0", 46 | "@types/node": "^16.0.0", 47 | "@types/react": "^17.0.0", 48 | "@types/react-dom": "^17.0.0", 49 | "@types/react-loadable": "^5.4.1", 50 | "@types/react-redux": "^7.0.0", 51 | "@types/react-router-dom": "^5.0.0", 52 | "@types/react-test-renderer": "^17.0.0", 53 | "@types/redux-actions": "^2.3.0", 54 | "@types/redux-auth-wrapper": "^2.0.8", 55 | "@types/redux-mock-store": "^1.0.0", 56 | "enzyme": "^3.9.0", 57 | "enzyme-adapter-react-16": "^1.12.1", 58 | "jest-styled-components": "^7.0.0", 59 | "mockdate": "^3.0.0", 60 | "react-test-renderer": "^17.0.0", 61 | "redux-mock-store": "^1.5.3", 62 | "serverless-s3-remover": "^0.6.0", 63 | "serverless-single-page-app-plugin": "file:./serverless-single-page-app-plugin", 64 | "source-map-explorer": "^2.0.0", 65 | "tslint": "^6.0.0", 66 | "tslint-config-prettier": "^1.18.0", 67 | "tslint-react": "^5.0.0", 68 | "typescript": "^4.0.0" 69 | }, 70 | "jest": { 71 | "collectCoverageFrom": [ 72 | "src/**/*.{js,jsx,ts,tsx}", 73 | "!src/registerServiceWorker.ts", 74 | "!src/index.tsx" 75 | ] 76 | }, 77 | "author": "Erez Rokah", 78 | "license": "MIT", 79 | "browserslist": { 80 | "production": [ 81 | ">0.2%", 82 | "not dead", 83 | "not op_mini all" 84 | ], 85 | "development": [ 86 | "last 1 chrome version", 87 | "last 1 firefox version", 88 | "last 1 safari version" 89 | ] 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erezrokah/serverless-monorepo-app/49044f3bf232cff49e1cca8114de6a0a05d1dad4/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | Serverless App 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Serverless App", 3 | "name": "Serverless Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/serverless-single-page-app-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-single-page-app-plugin", 3 | "version": "1.0.0", 4 | "description": "A plugin to simplify deploying Single Page Application using S3 and CloudFront", 5 | "author": "", 6 | "license": "MIT" 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | import { MemoryRouter } from 'react-router-dom'; 5 | import * as renderer from 'react-test-renderer'; 6 | import configureStore from 'redux-mock-store'; 7 | import { syncAuthStateRequested as syncAuthState } from './actions/auth'; 8 | import ConnectedApp, { App } from './App'; 9 | 10 | const middlewares: any[] = []; 11 | const mockStore = configureStore(middlewares); 12 | 13 | jest.mock('./components/Api', () => 'api-component'); 14 | 15 | jest.mock('./components/AuthStatus', () => 'auth-status-component'); 16 | 17 | jest.mock('./components/Loading', () => { 18 | return { 19 | ComponentLoading: 'component-loading-component', 20 | default: 'loading-component', 21 | __esModule: true, 22 | }; 23 | }); 24 | 25 | jest.mock('./selectors/auth'); 26 | jest.mock('./selectors/router'); 27 | 28 | const props = { 29 | initialized: true, 30 | pathname: '/public', 31 | syncAuthState: jest.fn(), 32 | }; 33 | 34 | describe('App', () => { 35 | beforeEach(() => { 36 | jest.clearAllMocks(); 37 | }); 38 | 39 | test('renders without crashing', () => { 40 | const div = document.createElement('div'); 41 | ReactDOM.render( 42 | 43 | 44 | , 45 | div, 46 | ); 47 | ReactDOM.unmountComponentAtNode(div); 48 | }); 49 | 50 | test('renders correctly initialized=true', () => { 51 | const tree = renderer 52 | .create( 53 | 54 | 55 | , 56 | ) 57 | .toJSON(); 58 | expect(tree).toMatchSnapshot(); 59 | }); 60 | 61 | test('renders correctly initialized=false', () => { 62 | const tree = renderer 63 | .create() 64 | .toJSON(); 65 | expect(tree).toMatchSnapshot(); 66 | }); 67 | 68 | test('syncs auth state on mount', () => { 69 | const wrapper = shallow(, { 70 | disableLifecycleMethods: true, 71 | }); 72 | 73 | // @ts-ignore access to undefined 74 | wrapper.instance().componentDidMount(); 75 | 76 | expect(props.syncAuthState).toHaveBeenCalledTimes(1); 77 | }); 78 | 79 | test('should connect component correctly', () => { 80 | const { isInitialized } = require('./selectors/auth'); 81 | isInitialized.mockReturnValueOnce(true); 82 | 83 | const { pathnameSelector } = require('./selectors/router'); 84 | pathnameSelector.mockReturnValueOnce('somepath'); 85 | 86 | const state = { someState: 'someState' }; 87 | const store = mockStore(state); 88 | 89 | // @ts-ignore wrong store type 90 | const wrapper = shallow(); 91 | 92 | expect(wrapper.props().initialized).toBe(true); 93 | expect(wrapper.props().pathname).toBe('somepath'); 94 | wrapper.props().syncAuthState(); 95 | 96 | expect(store.getActions()).toHaveLength(1); 97 | expect(store.getActions()[0]).toEqual(syncAuthState()); 98 | 99 | expect(isInitialized).toHaveBeenCalledTimes(1); 100 | expect(isInitialized).toHaveBeenCalledWith(state); 101 | 102 | expect(pathnameSelector).toHaveBeenCalledTimes(1); 103 | expect(pathnameSelector).toHaveBeenCalledWith(state); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Link, Switch } from 'react-router-dom'; 4 | import { Container, List, Segment } from 'semantic-ui-react'; 5 | import { syncAuthStateRequested as syncAuthState } from './actions/auth'; 6 | import Api from './components/Api'; 7 | import AuthStatus from './components/AuthStatus'; 8 | import Loading from './components/Loading'; 9 | import { paths, routes } from './routes'; 10 | import { isInitialized } from './selectors/auth'; 11 | import { pathnameSelector } from './selectors/router'; 12 | import { IState } from './types/redux'; 13 | 14 | interface IStoreProps { 15 | initialized: boolean; 16 | pathname: string; 17 | } 18 | 19 | interface IProps extends IStoreProps { 20 | syncAuthState: typeof syncAuthState; 21 | } 22 | 23 | export class App extends React.Component { 24 | public componentDidMount() { 25 | this.props.syncAuthState(); 26 | } 27 | 28 | public render() { 29 | if (!this.props.initialized) { 30 | return ( 31 | 32 | 33 | 34 | ); 35 | } 36 | const { pathname } = this.props; 37 | return ( 38 | 39 | 40 | 41 | 46 | Public Page 47 | 48 | 53 | Private Page 54 | 55 | 56 | 57 | 58 | {routes.Home} 59 | {routes.Public} 60 | {routes.Private} 61 | {routes.Login} 62 | {routes.Callback} 63 | {routes.NotFound} 64 | 65 | 66 | 67 | 68 | ); 69 | } 70 | } 71 | 72 | const mapStateToProps = (state: IState) => ({ 73 | initialized: isInitialized(state), 74 | pathname: pathnameSelector(state), 75 | }); 76 | 77 | export default connect( 78 | mapStateToProps, 79 | { syncAuthState }, 80 | )(App); 81 | -------------------------------------------------------------------------------- /frontend/src/__mocks__/auth0-js.ts: -------------------------------------------------------------------------------- 1 | export const authorize = jest.fn(); 2 | export const parseHash = jest.fn(); 3 | 4 | const WebAuth = jest.fn().mockImplementation(() => { 5 | return { authorize, parseHash }; 6 | }); 7 | 8 | export { WebAuth }; 9 | -------------------------------------------------------------------------------- /frontend/src/__snapshots__/App.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App renders correctly initialized=false 1`] = ` 4 |
7 | 8 |
9 | `; 10 | 11 | exports[`App renders correctly initialized=true 1`] = ` 12 |
15 | 40 |
43 | Welcome! 44 |
45 | 46 | 47 |
48 | `; 49 | -------------------------------------------------------------------------------- /frontend/src/actions/api.test.ts: -------------------------------------------------------------------------------- 1 | import * as actions from './api'; 2 | import * as types from './types'; 3 | 4 | describe('Api Actions', () => { 5 | test(types.API_REQUESTED, () => { 6 | const type = 'type'; 7 | expect({ 8 | meta: { type }, 9 | type: types.API_REQUESTED, 10 | }).toEqual(actions.apiRequested(undefined, type)); 11 | }); 12 | 13 | test(types.API_FULFILLED, () => { 14 | const payload = 'payload'; 15 | const type = 'type'; 16 | expect({ 17 | meta: { type }, 18 | payload, 19 | type: types.API_FULFILLED, 20 | }).toEqual(actions.apiFulfilled(payload, type)); 21 | }); 22 | 23 | test(types.API_REJECTED, () => { 24 | const error = new Error('error'); 25 | const type = 'type'; 26 | expect({ 27 | error: true, 28 | meta: { type }, 29 | payload: error, 30 | type: types.API_REJECTED, 31 | }).toEqual(actions.apiRejected(error, type)); 32 | }); 33 | 34 | test('emailApiRequested', () => { 35 | const toAddress = 'toAddress'; 36 | expect({ 37 | meta: { type: actions.apiMetaTypes.email }, 38 | payload: toAddress, 39 | type: types.API_REQUESTED, 40 | }).toEqual(actions.emailApiRequested(toAddress)); 41 | }); 42 | 43 | test('privateApiRequested', () => { 44 | expect({ 45 | meta: { type: actions.apiMetaTypes.private }, 46 | type: types.API_REQUESTED, 47 | }).toEqual(actions.privateApiRequested()); 48 | }); 49 | 50 | test('publicApiRequested', () => { 51 | expect({ 52 | meta: { type: actions.apiMetaTypes.public }, 53 | type: types.API_REQUESTED, 54 | }).toEqual(actions.publicApiRequested()); 55 | }); 56 | 57 | test('fileApiRequested', () => { 58 | const fileUrl = 'fileUrl'; 59 | const key = 'key'; 60 | expect({ 61 | meta: { type: actions.apiMetaTypes.file }, 62 | payload: { fileUrl, key }, 63 | type: types.API_REQUESTED, 64 | }).toEqual(actions.fileApiRequested(fileUrl, key)); 65 | }); 66 | 67 | test('dbCreateApiRequested', () => { 68 | const text = 'text'; 69 | expect({ 70 | meta: { type: actions.apiMetaTypes.dbCreate }, 71 | payload: text, 72 | type: types.API_REQUESTED, 73 | }).toEqual(actions.dbCreateApiRequested(text)); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /frontend/src/actions/api.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions'; 2 | import * as types from './types'; 3 | 4 | export const apiMetaTypes = { 5 | dbCreate: 'dbCreate', 6 | email: 'email', 7 | file: 'file', 8 | private: 'private', 9 | public: 'public', 10 | }; 11 | 12 | export const apiRequested = createAction( 13 | types.API_REQUESTED, 14 | undefined, 15 | (_, type) => ({ type }), 16 | ); 17 | export const apiFulfilled = createAction( 18 | types.API_FULFILLED, 19 | undefined, 20 | (_, type) => ({ type }), 21 | ); 22 | export const apiRejected = createAction( 23 | types.API_REJECTED, 24 | undefined, 25 | (_, type) => ({ type }), 26 | ); 27 | 28 | export const emailApiRequested = (toAddress: string) => 29 | apiRequested(toAddress, apiMetaTypes.email); 30 | 31 | export const privateApiRequested = () => 32 | apiRequested(undefined, apiMetaTypes.private); 33 | 34 | export const publicApiRequested = () => 35 | apiRequested(undefined, apiMetaTypes.public); 36 | 37 | export const fileApiRequested = (fileUrl: string, key: string) => 38 | apiRequested({ fileUrl, key }, apiMetaTypes.file); 39 | 40 | export const dbCreateApiRequested = (text: string) => 41 | apiRequested(text, apiMetaTypes.dbCreate); 42 | -------------------------------------------------------------------------------- /frontend/src/actions/auth.test.ts: -------------------------------------------------------------------------------- 1 | import * as actions from './auth'; 2 | import * as types from './types'; 3 | 4 | describe('Auth Actions', () => { 5 | test(types.LOGIN_REQUESTED, () => { 6 | expect({ 7 | type: types.LOGIN_REQUESTED, 8 | }).toEqual(actions.loginRequested()); 9 | }); 10 | 11 | test(types.LOGIN_FULFILLED, () => { 12 | expect({ 13 | type: types.LOGIN_FULFILLED, 14 | }).toEqual(actions.loginFulfilled()); 15 | }); 16 | 17 | test(types.LOGIN_REJECTED, () => { 18 | const error = new Error('Unauthorized'); 19 | expect({ 20 | error: true, 21 | payload: error, 22 | type: types.LOGIN_REJECTED, 23 | }).toEqual(actions.loginRejected(error)); 24 | }); 25 | 26 | test(types.LOGOUT_REQUESTED, () => { 27 | expect({ 28 | type: types.LOGOUT_REQUESTED, 29 | }).toEqual(actions.logoutRequested()); 30 | }); 31 | 32 | test(types.LOGOUT_FULFILLED, () => { 33 | expect({ 34 | type: types.LOGOUT_FULFILLED, 35 | }).toEqual(actions.logoutFulfilled()); 36 | }); 37 | 38 | test(types.LOGOUT_REJECTED, () => { 39 | const error = new Error('error'); 40 | expect({ 41 | error: true, 42 | payload: error, 43 | type: types.LOGOUT_REJECTED, 44 | }).toEqual(actions.logoutRejected(error)); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /frontend/src/actions/auth.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions'; 2 | import * as types from './types'; 3 | 4 | export const loginRequested = createAction(types.LOGIN_REQUESTED); 5 | export const loginFulfilled = createAction(types.LOGIN_FULFILLED); 6 | export const loginRejected = createAction(types.LOGIN_REJECTED); 7 | 8 | export const logoutRequested = createAction(types.LOGOUT_REQUESTED); 9 | export const logoutFulfilled = createAction(types.LOGOUT_FULFILLED); 10 | export const logoutRejected = createAction(types.LOGOUT_REJECTED); 11 | 12 | export const syncAuthStateRequested = createAction( 13 | types.SYNC_AUTH_STATE_REQUESTED, 14 | ); 15 | export const syncAuthStateFulfilled = createAction( 16 | types.SYNC_AUTH_STATE_FULFILLED, 17 | ); 18 | export const syncAuthStateRejected = createAction( 19 | types.SYNC_AUTH_STATE_REJECTED, 20 | ); 21 | -------------------------------------------------------------------------------- /frontend/src/actions/types.ts: -------------------------------------------------------------------------------- 1 | const _REQUESTED = '_REQUESTED'; 2 | const _FULFILLED = '_FULFILLED'; 3 | const _REJECTED = '_REJECTED'; 4 | 5 | export const LOGIN_REQUESTED = 'LOGIN' + _REQUESTED; 6 | export const LOGIN_FULFILLED = 'LOGIN' + _FULFILLED; 7 | export const LOGIN_REJECTED = 'LOGIN' + _REJECTED; 8 | 9 | export const LOGOUT_REQUESTED = 'LOGOUT' + _REQUESTED; 10 | export const LOGOUT_FULFILLED = 'LOGOUT' + _FULFILLED; 11 | export const LOGOUT_REJECTED = 'LOGOUT' + _REJECTED; 12 | 13 | export const SYNC_AUTH_STATE_REQUESTED = 'SYNC_AUTH_STATE' + _REQUESTED; 14 | export const SYNC_AUTH_STATE_FULFILLED = 'SYNC_AUTH_STATE' + _FULFILLED; 15 | export const SYNC_AUTH_STATE_REJECTED = 'SYNC_AUTH_STATE' + _REJECTED; 16 | 17 | export const API_REQUESTED = 'API' + _REQUESTED; 18 | export const API_FULFILLED = 'API' + _FULFILLED; 19 | export const API_REJECTED = 'API' + _REJECTED; 20 | -------------------------------------------------------------------------------- /frontend/src/components/Api/DbApi.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import * as React from 'react'; 3 | import * as renderer from 'react-test-renderer'; 4 | import configureStore from 'redux-mock-store'; 5 | import { Form } from 'semantic-ui-react'; 6 | import { dbCreateApiRequested } from '../../actions/api'; 7 | import ConnectedDbApi, { DbApi } from './DbApi'; 8 | 9 | const middlewares: any[] = []; 10 | const mockStore = configureStore(middlewares); 11 | 12 | jest.mock('../../selectors/api'); 13 | 14 | const props = { 15 | dbCreateApiRequested: jest.fn(), 16 | error: null, 17 | inProgress: false, 18 | result: 'result', 19 | }; 20 | 21 | describe('Db Api Component', () => { 22 | beforeEach(() => { 23 | jest.clearAllMocks(); 24 | }); 25 | 26 | test('should render correctly', () => { 27 | const tree = renderer.create().toJSON(); 28 | expect(tree).toMatchSnapshot(); 29 | }); 30 | 31 | test('should render correctly with error', () => { 32 | const tree = renderer 33 | .create() 34 | .toJSON(); 35 | expect(tree).toMatchSnapshot(); 36 | }); 37 | 38 | test('should call db api on button click', () => { 39 | const wrapper = shallow(); 40 | 41 | const text = 'text'; 42 | 43 | wrapper.setState({ text }); 44 | 45 | wrapper.find(Form).simulate('submit'); 46 | 47 | expect(props.dbCreateApiRequested).toHaveBeenCalledTimes(1); 48 | expect(props.dbCreateApiRequested).toHaveBeenCalledWith(text); 49 | }); 50 | 51 | test('should update state on text change', () => { 52 | const wrapper = shallow(); 53 | 54 | wrapper.setState({ text: '' }); 55 | const text = 'text'; 56 | 57 | wrapper.find('#dbApi').simulate('change', {}, { name: text, value: text }); 58 | 59 | expect(wrapper.state()).toEqual({ text }); 60 | }); 61 | 62 | test('should connect component correctly', () => { 63 | const { dbCreateApiSelector } = require('../../selectors/api'); 64 | const mockState = { 65 | error: null, 66 | inProgress: true, 67 | result: 'result', 68 | }; 69 | dbCreateApiSelector.mockReturnValueOnce(mockState); 70 | const store = mockStore(); 71 | 72 | // @ts-ignore wrong store type 73 | const wrapper = shallow(); 74 | 75 | expect(wrapper.props().error).toBe(mockState.error); 76 | expect(wrapper.props().inProgress).toBe(mockState.inProgress); 77 | expect(wrapper.props().result).toBe(mockState.result); 78 | const text = 'text'; 79 | wrapper.props().dbCreateApiRequested(text); 80 | 81 | expect(store.getActions()).toHaveLength(1); 82 | expect(store.getActions()[0]).toEqual(dbCreateApiRequested(text)); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /frontend/src/components/Api/DbApi.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Button, Form, Message, InputOnChangeData } from 'semantic-ui-react'; 4 | import { dbCreateApiRequested } from '../../actions/api'; 5 | import { dbCreateApiSelector } from '../../selectors/api'; 6 | import { IApiState, IState as Store } from '../../types/redux'; 7 | 8 | interface IState { 9 | [key: string]: string; 10 | } 11 | 12 | interface IProps extends IApiState { 13 | dbCreateApiRequested: typeof dbCreateApiRequested; 14 | } 15 | 16 | interface IEventProps extends InputOnChangeData { 17 | name: string; 18 | } 19 | 20 | export class DbApi extends React.Component { 21 | constructor(props: IProps) { 22 | super(props); 23 | this.state = { text: '' }; 24 | } 25 | 26 | public render() { 27 | const { text } = this.state; 28 | const { error, inProgress, result } = this.props; 29 | return ( 30 |
36 | 37 | 46 | 47 | 38 | {ResultComponent} 39 | {ErrorComponent} 40 | 41 | ); 42 | } 43 | } 44 | 45 | const mapStateToProps = (state: Store) => ({ 46 | ...privateApiSelector(state), 47 | }); 48 | 49 | export default connect( 50 | mapStateToProps, 51 | { privateApiRequested }, 52 | )(PrivateApi); 53 | -------------------------------------------------------------------------------- /frontend/src/components/Api/PublicApi.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import * as React from 'react'; 3 | import * as renderer from 'react-test-renderer'; 4 | import configureStore from 'redux-mock-store'; 5 | import { Button } from 'semantic-ui-react'; 6 | import { publicApiRequested } from '../../actions/api'; 7 | import ConnectedPublicApi, { PublicApi } from './PublicApi'; 8 | 9 | const middlewares: any[] = []; 10 | const mockStore = configureStore(middlewares); 11 | 12 | jest.mock('../../selectors/api'); 13 | 14 | const props = { 15 | error: null, 16 | inProgress: false, 17 | publicApiRequested: jest.fn(), 18 | result: 'result', 19 | }; 20 | 21 | describe('Public Api Component', () => { 22 | beforeEach(() => { 23 | jest.clearAllMocks(); 24 | }); 25 | 26 | test('should render correctly', () => { 27 | const tree = renderer.create().toJSON(); 28 | expect(tree).toMatchSnapshot(); 29 | }); 30 | 31 | test('should render correctly with error', () => { 32 | const tree = renderer 33 | .create() 34 | .toJSON(); 35 | expect(tree).toMatchSnapshot(); 36 | }); 37 | 38 | test('should call public api on button click', () => { 39 | const wrapper = shallow(); 40 | 41 | wrapper.find(Button).simulate('click'); 42 | 43 | expect(props.publicApiRequested).toHaveBeenCalledTimes(1); 44 | }); 45 | 46 | test('should connect component correctly', () => { 47 | const { publicApiSelector } = require('../../selectors/api'); 48 | const mockState = { 49 | error: null, 50 | inProgress: true, 51 | result: 'result', 52 | }; 53 | publicApiSelector.mockReturnValueOnce(mockState); 54 | const store = mockStore(); 55 | 56 | // @ts-ignore wrong store type 57 | const wrapper = shallow(); 58 | 59 | expect(wrapper.props().error).toBe(mockState.error); 60 | expect(wrapper.props().inProgress).toBe(mockState.inProgress); 61 | expect(wrapper.props().result).toBe(mockState.result); 62 | wrapper.props().publicApiRequested(); 63 | 64 | expect(store.getActions()).toHaveLength(1); 65 | expect(store.getActions()[0]).toEqual(publicApiRequested()); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /frontend/src/components/Api/PublicApi.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Button, Message } from 'semantic-ui-react'; 4 | import { publicApiRequested } from '../../actions/api'; 5 | import { publicApiSelector } from '../../selectors/api'; 6 | import { IApiState, IState as Store } from '../../types/redux'; 7 | 8 | interface IProps extends IApiState { 9 | publicApiRequested: typeof publicApiRequested; 10 | } 11 | 12 | export class PublicApi extends React.Component { 13 | public render() { 14 | const { error, inProgress, result } = this.props; 15 | 16 | const ResultComponent = 17 | !error && result ? ( 18 | 19 | ) : null; 20 | 21 | const ErrorComponent = error ? ( 22 | 27 | ) : null; 28 | 29 | return ( 30 | 31 | 38 | {ResultComponent} 39 | {ErrorComponent} 40 | 41 | ); 42 | } 43 | } 44 | 45 | const mapStateToProps = (state: Store) => ({ 46 | ...publicApiSelector(state), 47 | }); 48 | 49 | export default connect( 50 | mapStateToProps, 51 | { publicApiRequested }, 52 | )(PublicApi); 53 | -------------------------------------------------------------------------------- /frontend/src/components/Api/__snapshots__/DbApi.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Db Api Component should render correctly 1`] = ` 4 |
8 |
11 |
14 |
17 | 26 |
27 |
28 | 35 |
36 |
39 |
42 |
45 | Todos Api Result 46 |
47 |

48 | result 49 |

50 |
51 |
52 |
55 |
58 |
61 | Error Saving File 62 |
63 |
64 |
65 |
66 | `; 67 | 68 | exports[`Db Api Component should render correctly with error 1`] = ` 69 |
73 |
76 |
79 |
82 | 91 |
92 |
93 | 100 |
101 |
104 |
107 |
110 | Todos Api Result 111 |
112 |

113 | result 114 |

115 |
116 |
117 |
120 |
123 |
126 | Error Saving File 127 |
128 |

129 | error 130 |

131 |
132 |
133 |
134 | `; 135 | -------------------------------------------------------------------------------- /frontend/src/components/Api/__snapshots__/EmailApi.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Email Api Component should render correctly 1`] = ` 4 |
8 |
11 |
14 |
17 | 26 |
27 |
28 | 35 |
36 |
39 |
42 |
45 | Email Api Result 46 |
47 |

48 | result 49 |

50 |
51 |
52 |
55 |
58 |
61 | Error Sending Email 62 |
63 |
64 |
65 |
66 | `; 67 | 68 | exports[`Email Api Component should render correctly with error 1`] = ` 69 |
73 |
76 |
79 |
82 | 91 |
92 |
93 | 100 |
101 |
104 |
107 |
110 | Email Api Result 111 |
112 |

113 | result 114 |

115 |
116 |
117 |
120 |
123 |
126 | Error Sending Email 127 |
128 |

129 | error 130 |

131 |
132 |
133 |
134 | `; 135 | -------------------------------------------------------------------------------- /frontend/src/components/Api/__snapshots__/FileApi.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`File Api Component should render correctly 1`] = ` 4 |
8 |
11 |
14 |
17 | 26 |
27 |
28 |
31 |
34 | 43 |
44 |
45 | 52 |
53 |
56 |
59 |
62 | File Api Result 63 |
64 |

65 | result 66 |

67 |
68 |
69 |
72 |
75 |
78 | Error Saving File 79 |
80 |
81 |
82 |
83 | `; 84 | 85 | exports[`File Api Component should render correctly with error 1`] = ` 86 |
90 |
93 |
96 |
99 | 108 |
109 |
110 |
113 |
116 | 125 |
126 |
127 | 134 |
135 |
138 |
141 |
144 | File Api Result 145 |
146 |

147 | result 148 |

149 |
150 |
151 |
154 |
157 |
160 | Error Saving File 161 |
162 |

163 | error 164 |

165 |
166 |
167 |
168 | `; 169 | -------------------------------------------------------------------------------- /frontend/src/components/Api/__snapshots__/PrivateApi.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Private Api Component should render correctly 1`] = ` 4 | Array [ 5 | , 12 |
15 |
18 |
21 | Private Api Result 22 |
23 |

24 | result 25 |

26 |
27 |
, 28 | ] 29 | `; 30 | 31 | exports[`Private Api Component should render correctly with error 1`] = ` 32 | Array [ 33 | , 40 |
43 |
46 |
49 | Error Accessing Private Api 50 |
51 |

52 | error 53 |

54 |
55 |
, 56 | ] 57 | `; 58 | -------------------------------------------------------------------------------- /frontend/src/components/Api/__snapshots__/PublicApi.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Public Api Component should render correctly 1`] = ` 4 | Array [ 5 | , 12 |
15 |
18 |
21 | Public Api Result 22 |
23 |

24 | result 25 |

26 |
27 |
, 28 | ] 29 | `; 30 | 31 | exports[`Public Api Component should render correctly with error 1`] = ` 32 | Array [ 33 | , 40 |
43 |
46 |
49 | Error Accessing Public Api 50 |
51 |

52 | error 53 |

54 |
55 |
, 56 | ] 57 | `; 58 | -------------------------------------------------------------------------------- /frontend/src/components/Api/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Api Component should render correctly 1`] = ` 4 |
7 |
10 | 11 |
12 |
15 | 16 |
17 |
20 | 21 |
22 |
25 | 26 |
27 |
30 | 31 |
32 |
33 | `; 34 | -------------------------------------------------------------------------------- /frontend/src/components/Api/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as renderer from 'react-test-renderer'; 3 | import Api from './'; 4 | 5 | jest.mock('./EmailApi', () => 'EmailApi'); 6 | jest.mock('./PrivateApi', () => 'PrivateApi'); 7 | jest.mock('./PublicApi', () => 'PublicApi'); 8 | jest.mock('./DbApi', () => 'DbApi'); 9 | jest.mock('./FileApi', () => 'FileApi'); 10 | 11 | describe('Api Component', () => { 12 | test('should render correctly', () => { 13 | const tree = renderer.create().toJSON(); 14 | expect(tree).toMatchSnapshot(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /frontend/src/components/Api/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Segment } from 'semantic-ui-react'; 3 | import DbApi from './DbApi'; 4 | import EmailApi from './EmailApi'; 5 | import FileApi from './FileApi'; 6 | import PrivateApi from './PrivateApi'; 7 | import PublicApi from './PublicApi'; 8 | 9 | export class Api extends React.Component { 10 | public render() { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | } 32 | 33 | export default Api; 34 | -------------------------------------------------------------------------------- /frontend/src/components/AuthStatus/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`AuthStatus Component should render correctly authenticated=false 1`] = ` 4 |
7 | You are not logged in. 8 |
9 | `; 10 | 11 | exports[`AuthStatus Component should render correctly authenticated=true 1`] = ` 12 |
15 | 22 |
23 | `; 24 | -------------------------------------------------------------------------------- /frontend/src/components/AuthStatus/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import * as React from 'react'; 3 | import * as renderer from 'react-test-renderer'; 4 | import configureStore from 'redux-mock-store'; 5 | import { Button } from 'semantic-ui-react'; 6 | import { logoutRequested as logout } from '../../actions/auth'; 7 | import ConnectedAuthStatus, { AuthStatus } from './'; 8 | 9 | jest.mock('../../selectors/auth'); 10 | 11 | const middlewares: any[] = []; 12 | const mockStore = configureStore(middlewares); 13 | 14 | const props = { 15 | authenticated: false, 16 | logout: jest.fn(), 17 | }; 18 | 19 | describe('AuthStatus Component', () => { 20 | beforeEach(() => { 21 | jest.clearAllMocks(); 22 | }); 23 | test('should render correctly authenticated=false', () => { 24 | const tree = renderer 25 | .create() 26 | .toJSON(); 27 | expect(tree).toMatchSnapshot(); 28 | }); 29 | 30 | test('should render correctly authenticated=true', () => { 31 | const tree = renderer 32 | .create() 33 | .toJSON(); 34 | expect(tree).toMatchSnapshot(); 35 | }); 36 | 37 | test('should call logout on button click', () => { 38 | const wrapper = shallow(); 39 | 40 | wrapper.find(Button).simulate('click'); 41 | 42 | expect(props.logout).toHaveBeenCalledTimes(1); 43 | }); 44 | 45 | test('should connect component correctly', () => { 46 | const { isAuthenticated } = require('../../selectors/auth'); 47 | isAuthenticated.mockReturnValueOnce(true); 48 | 49 | const state = { someState: 'someState' }; 50 | const store = mockStore(state); 51 | 52 | // @ts-ignore wrong store type 53 | const wrapper = shallow(); 54 | 55 | expect(wrapper.props().authenticated).toBe(true); 56 | wrapper.props().logout(); 57 | 58 | expect(store.getActions()).toHaveLength(1); 59 | expect(store.getActions()[0]).toEqual(logout()); 60 | 61 | expect(isAuthenticated).toHaveBeenCalledTimes(1); 62 | expect(isAuthenticated).toHaveBeenCalledWith(state); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /frontend/src/components/AuthStatus/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Button, Segment } from 'semantic-ui-react'; 4 | import { logoutRequested as logout } from '../../actions/auth'; 5 | import { isAuthenticated } from '../../selectors/auth'; 6 | import { IState } from '../../types/redux'; 7 | 8 | interface IStoreProps { 9 | authenticated: boolean; 10 | } 11 | 12 | interface IProps extends IStoreProps { 13 | logout: typeof logout; 14 | } 15 | 16 | export const AuthStatus: React.SFC = props => { 17 | if (props.authenticated) { 18 | return ( 19 | 20 | 21 | 22 | ); 23 | } else { 24 | return You are not logged in.; 25 | } 26 | }; 27 | 28 | const mapStateToProps = (state: IState): IStoreProps => ({ 29 | authenticated: isAuthenticated(state), 30 | }); 31 | 32 | export default connect( 33 | mapStateToProps, 34 | { logout }, 35 | )(AuthStatus); 36 | -------------------------------------------------------------------------------- /frontend/src/components/Callback/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Callback Component should render correctly 1`] = ``; 4 | -------------------------------------------------------------------------------- /frontend/src/components/Callback/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { replace } from 'connected-react-router'; 2 | import { shallow } from 'enzyme'; 3 | import * as React from 'react'; 4 | import * as renderer from 'react-test-renderer'; 5 | import configureStore from 'redux-mock-store'; 6 | import { loginFulfilled, loginRejected } from '../../actions/auth'; 7 | import { paths } from '../../routes'; 8 | import ConnectedCallback, { Callback } from './'; 9 | 10 | jest.mock('../../lib/auth'); 11 | jest.mock('../Loading', () => { 12 | return { 13 | ComponentLoading: 'component-loading-component', 14 | default: 'loading-component', 15 | __esModule: true, 16 | }; 17 | }); 18 | 19 | const middlewares: any[] = []; 20 | const mockStore = configureStore(middlewares); 21 | 22 | const props = { 23 | loginFulfilled: jest.fn(), 24 | loginRejected: jest.fn(), 25 | replace: jest.fn(), 26 | }; 27 | 28 | describe('Callback Component', () => { 29 | beforeEach(() => { 30 | jest.clearAllMocks(); 31 | }); 32 | 33 | test('should render correctly', () => { 34 | const tree = renderer.create().toJSON(); 35 | expect(tree).toMatchSnapshot(); 36 | }); 37 | 38 | test('handles authentication on mount with auth state', async () => { 39 | const { handleAuthentication } = require('../../lib/auth'); 40 | 41 | const wrapper = shallow(, { 42 | disableLifecycleMethods: true, 43 | }); 44 | 45 | const authResult = { state: 'state' }; 46 | handleAuthentication.mockReturnValueOnce(Promise.resolve(authResult)); 47 | 48 | // @ts-ignore access to undefined 49 | await wrapper.instance().componentDidMount(); 50 | 51 | expect(handleAuthentication).toHaveBeenCalledTimes(1); 52 | expect(props.loginFulfilled).toHaveBeenCalledTimes(1); 53 | expect(props.loginFulfilled).toHaveBeenCalledWith(authResult); 54 | expect(props.replace).toHaveBeenCalledTimes(1); 55 | expect(props.replace).toHaveBeenCalledWith(authResult.state); 56 | }); 57 | 58 | test('handles authentication on mount no auth state', async () => { 59 | const { handleAuthentication } = require('../../lib/auth'); 60 | 61 | const wrapper = shallow(, { 62 | disableLifecycleMethods: true, 63 | }); 64 | 65 | const authResult = {}; 66 | handleAuthentication.mockReturnValueOnce(Promise.resolve(authResult)); 67 | 68 | // @ts-ignore access to undefined 69 | await wrapper.instance().componentDidMount(); 70 | 71 | expect(props.replace).toHaveBeenCalledWith('/public'); 72 | }); 73 | 74 | test('handles authentication on mount with error', async () => { 75 | const { handleAuthentication } = require('../../lib/auth'); 76 | 77 | const wrapper = shallow(, { 78 | disableLifecycleMethods: true, 79 | }); 80 | 81 | const error = new Error('error'); 82 | handleAuthentication.mockReturnValueOnce(Promise.reject(error)); 83 | 84 | // @ts-ignore access to undefined 85 | await wrapper.instance().componentDidMount(); 86 | 87 | expect(props.loginRejected).toHaveBeenCalledTimes(1); 88 | expect(props.loginRejected).toHaveBeenCalledWith(error); 89 | expect(props.replace).toHaveBeenCalledTimes(1); 90 | expect(props.replace).toHaveBeenCalledWith(paths.login); 91 | }); 92 | 93 | test('should connect component correctly', () => { 94 | const store = mockStore(); 95 | 96 | // @ts-ignore wrong store type 97 | const wrapper = shallow(); 98 | 99 | wrapper.props().loginFulfilled(); 100 | wrapper.props().loginRejected(); 101 | wrapper.props().replace('/'); 102 | 103 | expect(store.getActions()).toHaveLength(3); 104 | expect(store.getActions()[0]).toEqual(loginFulfilled()); 105 | expect(store.getActions()[1]).toEqual(loginRejected()); 106 | expect(store.getActions()[2]).toEqual(replace(paths.home)); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /frontend/src/components/Callback/index.tsx: -------------------------------------------------------------------------------- 1 | import { replace } from 'connected-react-router'; 2 | import * as React from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { loginFulfilled, loginRejected } from '../../actions/auth'; 5 | import { handleAuthentication } from '../../lib/auth'; 6 | import { paths } from '../../routes'; 7 | import Loading from '../Loading'; 8 | 9 | interface IProps { 10 | loginFulfilled: typeof loginFulfilled; 11 | loginRejected: typeof loginRejected; 12 | replace: typeof replace; 13 | } 14 | 15 | export class Callback extends React.Component { 16 | public async componentDidMount() { 17 | try { 18 | const authResult = await handleAuthentication(); 19 | this.props.loginFulfilled(authResult); 20 | this.props.replace(authResult.state || paths.public); 21 | } catch (err) { 22 | this.props.loginRejected(err); 23 | this.props.replace(paths.login); 24 | } 25 | } 26 | 27 | public render() { 28 | return ; 29 | } 30 | } 31 | 32 | export default connect( 33 | null, 34 | { loginFulfilled, loginRejected, replace }, 35 | )(Callback); 36 | -------------------------------------------------------------------------------- /frontend/src/components/Home/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Home Component should render correctly 1`] = ` 4 |
7 | Welcome! 8 |
9 | `; 10 | -------------------------------------------------------------------------------- /frontend/src/components/Home/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as renderer from 'react-test-renderer'; 3 | import Home from './'; 4 | 5 | describe('Home Component', () => { 6 | test('should render correctly', () => { 7 | const tree = renderer.create().toJSON(); 8 | expect(tree).toMatchSnapshot(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /frontend/src/components/Home/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Segment } from 'semantic-ui-react'; 3 | 4 | const Home = () => Welcome!; 5 | 6 | export default Home; 7 | -------------------------------------------------------------------------------- /frontend/src/components/Loading/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Loading Components ComponentLoading should render correctly 1`] = `null`; 4 | 5 | exports[`Loading Components ComponentLoading should render correctly with error 1`] = ` 6 |
9 | Error! 10 | 17 |
18 | `; 19 | 20 | exports[`Loading Components ComponentLoading should render correctly with pastDelay 1`] = ` 21 |
24 | Loading... 25 |
26 | `; 27 | 28 | exports[`Loading Components ComponentLoading should render correctly with timedOut 1`] = ` 29 |
32 | Taking a long time... 33 | 40 |
41 | `; 42 | 43 | exports[`Loading Components Loading should render correctly 1`] = ` 44 |
48 |
51 |
54 | Loading 55 |
56 |
57 |
58 | `; 59 | -------------------------------------------------------------------------------- /frontend/src/components/Loading/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import * as React from 'react'; 3 | import * as renderer from 'react-test-renderer'; 4 | import { Button } from 'semantic-ui-react'; 5 | import Loading, { ComponentLoading } from './'; 6 | 7 | describe('Loading Components', () => { 8 | describe('Loading', () => { 9 | test('should render correctly', () => { 10 | const tree = renderer.create().toJSON(); 11 | expect(tree).toMatchSnapshot(); 12 | }); 13 | }); 14 | 15 | describe('ComponentLoading', () => { 16 | const props = { 17 | error: null, 18 | isLoading: false, 19 | pastDelay: false, 20 | retry: jest.fn(), 21 | timedOut: false, 22 | }; 23 | 24 | beforeEach(() => { 25 | jest.clearAllMocks(); 26 | }); 27 | 28 | test('should render correctly', () => { 29 | const tree = renderer.create().toJSON(); 30 | expect(tree).toMatchSnapshot(); 31 | }); 32 | 33 | test('should render correctly with error', () => { 34 | const tree = renderer 35 | .create() 36 | .toJSON(); 37 | expect(tree).toMatchSnapshot(); 38 | }); 39 | 40 | test('should render correctly with timedOut', () => { 41 | const tree = renderer 42 | .create() 43 | .toJSON(); 44 | expect(tree).toMatchSnapshot(); 45 | }); 46 | 47 | test('should render correctly with pastDelay', () => { 48 | const tree = renderer 49 | .create() 50 | .toJSON(); 51 | expect(tree).toMatchSnapshot(); 52 | }); 53 | 54 | test('should retry on click (error)', () => { 55 | const wrapper = shallow( 56 | , 57 | ); 58 | 59 | wrapper.find(Button).simulate('click'); 60 | 61 | expect(props.retry).toHaveBeenCalledTimes(1); 62 | }); 63 | 64 | test('should retry on click (timeout)', () => { 65 | const wrapper = shallow(); 66 | 67 | wrapper.find(Button).simulate('click'); 68 | 69 | expect(props.retry).toHaveBeenCalledTimes(1); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /frontend/src/components/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { LoadingComponentProps } from 'react-loadable'; 3 | import { Button, Dimmer, Loader, Segment } from 'semantic-ui-react'; 4 | 5 | export const Loading = () => ( 6 | 7 | Loading 8 | 9 | ); 10 | 11 | export const ComponentLoading = (props: LoadingComponentProps) => { 12 | if (props.error) { 13 | return ( 14 | 15 | Error! 16 | 17 | ); 18 | } else if (props.timedOut) { 19 | return ( 20 | 21 | Taking a long time... 22 | 23 | ); 24 | } else if (props.pastDelay) { 25 | return Loading...; 26 | } else { 27 | return null; 28 | } 29 | }; 30 | 31 | export default Loading; 32 | -------------------------------------------------------------------------------- /frontend/src/components/Login/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Callback Component should render correctly 1`] = ` 4 |
7 | 14 |
15 | `; 16 | -------------------------------------------------------------------------------- /frontend/src/components/Login/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import * as React from 'react'; 3 | import * as renderer from 'react-test-renderer'; 4 | import configureStore from 'redux-mock-store'; 5 | import { Button } from 'semantic-ui-react'; 6 | import { loginRequested as login } from '../../actions/auth'; 7 | import ConnectedLogin, { Login } from './'; 8 | 9 | jest.mock('../../lib/auth'); 10 | 11 | const middlewares: any[] = []; 12 | const mockStore = configureStore(middlewares); 13 | 14 | const props = { 15 | isAuthenticated: false, 16 | isAuthenticating: false, 17 | login: jest.fn(), 18 | redirectPath: '/', 19 | }; 20 | 21 | describe('Callback Component', () => { 22 | beforeEach(() => { 23 | jest.clearAllMocks(); 24 | }); 25 | 26 | test('should render correctly', () => { 27 | const tree = renderer.create().toJSON(); 28 | expect(tree).toMatchSnapshot(); 29 | }); 30 | 31 | test('invokes login on button click', async () => { 32 | const wrapper = shallow(); 33 | 34 | wrapper.find(Button).simulate('click'); 35 | 36 | expect(props.login).toHaveBeenCalledTimes(1); 37 | }); 38 | 39 | test('should connect component correctly', () => { 40 | const store = mockStore(); 41 | 42 | // @ts-ignore wrong store type 43 | const wrapper = shallow(); 44 | 45 | wrapper.props().login(); 46 | 47 | expect(store.getActions()).toHaveLength(1); 48 | expect(store.getActions()[0]).toEqual(login()); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /frontend/src/components/Login/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { InjectedAuthReduxProps } from 'redux-auth-wrapper/history4/redirect'; 4 | import { Button, Segment } from 'semantic-ui-react'; 5 | import { loginRequested as login } from '../../actions/auth'; 6 | 7 | interface IProps extends InjectedAuthReduxProps { 8 | login: typeof login; 9 | } 10 | 11 | export class Login extends React.Component { 12 | public render() { 13 | return ( 14 | 15 | 16 | 17 | ); 18 | } 19 | 20 | private login = () => { 21 | this.props.login(); 22 | }; 23 | } 24 | 25 | export default connect( 26 | null, 27 | { login }, 28 | )(Login); 29 | -------------------------------------------------------------------------------- /frontend/src/components/NotFound/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`NotFound Component should render correctly 1`] = ` 4 |
7 | Sorry, this page does not exist. 8 |
9 | `; 10 | -------------------------------------------------------------------------------- /frontend/src/components/NotFound/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as renderer from 'react-test-renderer'; 3 | import NotFound from './'; 4 | 5 | describe('NotFound Component', () => { 6 | test('should render correctly', () => { 7 | const tree = renderer.create().toJSON(); 8 | expect(tree).toMatchSnapshot(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /frontend/src/components/NotFound/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Segment } from 'semantic-ui-react'; 3 | 4 | const NotFound = () => Sorry, this page does not exist.; 5 | 6 | export default NotFound; 7 | -------------------------------------------------------------------------------- /frontend/src/components/Private/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Private Component should render correctly 1`] = ` 4 |
7 | This is a private page 8 |
9 | `; 10 | -------------------------------------------------------------------------------- /frontend/src/components/Private/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as renderer from 'react-test-renderer'; 3 | import Private from './'; 4 | 5 | describe('Private Component', () => { 6 | test('should render correctly', () => { 7 | const tree = renderer 8 | .create( 9 | , 14 | ) 15 | .toJSON(); 16 | expect(tree).toMatchSnapshot(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /frontend/src/components/Private/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { InjectedAuthReduxProps } from 'redux-auth-wrapper/history4/redirect'; 3 | import { Segment } from 'semantic-ui-react'; 4 | 5 | export class Private extends React.Component { 6 | public render() { 7 | return This is a private page; 8 | } 9 | } 10 | 11 | export default Private; 12 | -------------------------------------------------------------------------------- /frontend/src/components/Public/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Public Component should render correctly 1`] = ` 4 |
7 | This is a public page 8 |
9 | `; 10 | -------------------------------------------------------------------------------- /frontend/src/components/Public/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as renderer from 'react-test-renderer'; 3 | import Public from './'; 4 | 5 | describe('Public Component', () => { 6 | test('should render correctly', () => { 7 | const tree = renderer.create().toJSON(); 8 | expect(tree).toMatchSnapshot(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /frontend/src/components/Public/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Segment } from 'semantic-ui-react'; 3 | 4 | class Public extends React.Component { 5 | public render() { 6 | return This is a public page; 7 | } 8 | } 9 | 10 | export default Public; 11 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ConnectedRouter, 3 | connectRouter, 4 | routerMiddleware, 5 | } from 'connected-react-router'; 6 | import { createBrowserHistory } from 'history'; 7 | import * as React from 'react'; 8 | import * as ReactDOM from 'react-dom'; 9 | import { Provider } from 'react-redux'; 10 | import { applyMiddleware, compose, createStore } from 'redux'; 11 | import createSagaMiddleware from 'redux-saga'; 12 | import 'semantic-ui-css/semantic.min.css'; 13 | import App from './App'; 14 | import './index.css'; 15 | import rootReducer from './reducers'; 16 | import registerServiceWorker from './registerServiceWorker'; 17 | import sagas from './sagas'; 18 | 19 | const devEnvironment = 20 | !process.env.NODE_ENV || process.env.NODE_ENV === 'development'; 21 | 22 | const history = createBrowserHistory(); 23 | 24 | const sagaMiddleware = createSagaMiddleware(); 25 | 26 | const applyAllMiddleware = () => { 27 | /* istanbul ignore if */ 28 | if (devEnvironment) { 29 | const { createLogger } = require('redux-logger'); 30 | const logger = createLogger(); 31 | return applyMiddleware(sagaMiddleware, routerMiddleware(history), logger); 32 | } else { 33 | return applyMiddleware(sagaMiddleware, routerMiddleware(history)); 34 | } 35 | }; 36 | 37 | const __REDUX_DEVTOOLS_EXTENSION_COMPOSE__ = 38 | // @ts-ignore: property does not exist 39 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; 40 | 41 | /* istanbul ignore next */ 42 | const loadExtension = 43 | devEnvironment && 44 | typeof window === 'object' && 45 | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__; 46 | 47 | const composeEnhancers = loadExtension 48 | ? /* istanbul ignore next */ 49 | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}) 50 | : compose; 51 | 52 | const store = createStore( 53 | connectRouter(history)(rootReducer), 54 | composeEnhancers(applyAllMiddleware()), 55 | ); 56 | 57 | sagaMiddleware.run(sagas); 58 | 59 | ReactDOM.render( 60 | 61 | 62 | 63 | 64 | , 65 | document.getElementById('root') as HTMLElement, 66 | ); 67 | registerServiceWorker(); 68 | -------------------------------------------------------------------------------- /frontend/src/lib/api.ts: -------------------------------------------------------------------------------- 1 | const ENDPOINT = process.env.REACT_APP_API_SERVICE_ENDPOINT || ''; 2 | 3 | const PUBLIC_ENDPOINT = `${ENDPOINT}/api/public`; 4 | const PRIVATE_ENDPOINT = `${ENDPOINT}/api/private`; 5 | 6 | const EMAIL_ENDPOINT = `${process.env.REACT_APP_EMAIL_SERVICE_ENDPOINT || 7 | ''}/email`; 8 | 9 | const FILE_ENDPOINT = `${process.env.REACT_APP_FILE_SERVICE_ENDPOINT || 10 | ''}/save`; 11 | 12 | const DB_ENDPOINT = `${process.env.REACT_APP_DB_SERVICE_ENDPOINT || ''}/todos`; 13 | 14 | export const checkStatus = (response: Response, message: string) => { 15 | if (response.ok) { 16 | return response; 17 | } else { 18 | const error = new Error(message); 19 | throw error; 20 | } 21 | }; 22 | 23 | export const publicApi = async () => { 24 | const response = await fetch(PUBLIC_ENDPOINT, { 25 | cache: 'no-store', 26 | method: 'POST', 27 | }); 28 | 29 | const { message } = await response.json(); 30 | return message; 31 | }; 32 | 33 | export const privateApi = async () => { 34 | const token = localStorage.getItem('id_token'); 35 | const response = await fetch(PRIVATE_ENDPOINT, { 36 | headers: { 37 | Authorization: `Bearer ${token}`, 38 | }, 39 | method: 'POST', 40 | }); 41 | 42 | const { message } = await response.json(); 43 | checkStatus(response, message); 44 | 45 | return message; 46 | }; 47 | 48 | export const emailApi = async (toAddress: string) => { 49 | const token = localStorage.getItem('id_token'); 50 | const response = await fetch(EMAIL_ENDPOINT, { 51 | body: JSON.stringify({ to_address: toAddress }), 52 | headers: { 53 | Authorization: `Bearer ${token}`, 54 | }, 55 | method: 'POST', 56 | }); 57 | 58 | const { message } = await response.json(); 59 | checkStatus(response, message); 60 | 61 | return message; 62 | }; 63 | 64 | interface IFileApiPayload { 65 | fileUrl: string; 66 | key: string; 67 | } 68 | 69 | export const saveFile = async ({ fileUrl, key }: IFileApiPayload) => { 70 | const response = await fetch(FILE_ENDPOINT, { 71 | body: JSON.stringify({ file_url: fileUrl, key }), 72 | method: 'POST', 73 | }); 74 | 75 | const { message } = await response.json(); 76 | checkStatus(response, message); 77 | 78 | return message; 79 | }; 80 | 81 | export const createTodosItem = async (text: string) => { 82 | const response = await fetch(DB_ENDPOINT, { 83 | body: JSON.stringify({ text }), 84 | method: 'POST', 85 | }); 86 | 87 | const item = await response.json(); 88 | checkStatus(response, item); 89 | 90 | return JSON.stringify(item); 91 | }; 92 | -------------------------------------------------------------------------------- /frontend/src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { Auth0DecodedHash, WebAuth } from 'auth0-js'; 2 | 3 | const auth = new WebAuth({ 4 | audience: process.env.REACT_APP_AUTH0_AUDIENCE, 5 | clientID: process.env.REACT_APP_AUTH0_CLIENT_ID || '', 6 | domain: process.env.REACT_APP_AUTH0_DOMAIM || '', 7 | redirectUri: process.env.REACT_APP_AUTH0_REDIRECT_URI, 8 | responseType: 'token id_token', 9 | scope: 'openid profile email', 10 | }); 11 | 12 | const setSession = (authResult: Auth0DecodedHash) => { 13 | const { expiresIn = 0, accessToken = '', idToken = '' } = authResult; 14 | // Set the time that the access token will expire at 15 | const expiresAt = JSON.stringify(expiresIn * 1000 + new Date().getTime()); 16 | localStorage.setItem('access_token', accessToken); 17 | localStorage.setItem('id_token', idToken); 18 | localStorage.setItem('expires_at', expiresAt); 19 | }; 20 | 21 | const handleAuthentication = async () => { 22 | const promise: Promise = new Promise((resolve, reject) => { 23 | auth.parseHash((err, authResult) => { 24 | const result = authResult || {}; 25 | if (err) { 26 | reject(err); 27 | } else { 28 | setSession(result); 29 | resolve(result); 30 | } 31 | }); 32 | }); 33 | return await promise; 34 | }; 35 | 36 | const login = (state: string) => auth.authorize({ state }); 37 | 38 | const logout = () => { 39 | localStorage.removeItem('access_token'); 40 | localStorage.removeItem('id_token'); 41 | localStorage.removeItem('expires_at'); 42 | }; 43 | 44 | const isAuthenticated = () => { 45 | // Check whether the current time is past the 46 | // access token's expiry time 47 | const expiresAt = JSON.parse( 48 | localStorage.getItem('expires_at') || JSON.stringify(0), 49 | ); 50 | return new Date().getTime() < expiresAt; 51 | }; 52 | 53 | export { handleAuthentication, login, logout, isAuthenticated }; 54 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/src/reducers/api.test.ts: -------------------------------------------------------------------------------- 1 | import * as actions from '../actions/api'; 2 | import * as types from '../actions/types'; 3 | import reducer from './api'; 4 | 5 | describe('api reducer', () => { 6 | const metaTypes = Object.keys(actions.apiMetaTypes); 7 | 8 | for (const metaType of metaTypes) { 9 | test(types.API_REQUESTED, () => { 10 | const action = actions.apiRequested(undefined, metaType); 11 | 12 | expect( 13 | reducer( 14 | { 15 | [metaType]: { inProgress: false, error: null }, 16 | }, 17 | action, 18 | )[metaType], 19 | ).toEqual({ 20 | error: null, 21 | inProgress: true, 22 | result: null, 23 | }); 24 | }); 25 | 26 | test(types.API_FULFILLED, () => { 27 | const result = 'result'; 28 | const action = actions.apiFulfilled(result, metaType); 29 | 30 | expect( 31 | reducer({ [metaType]: { inProgress: true, error: null } }, action)[ 32 | metaType 33 | ], 34 | ).toEqual({ 35 | error: null, 36 | inProgress: false, 37 | result, 38 | }); 39 | }); 40 | 41 | test(types.API_REJECTED, () => { 42 | const error = new Error('error'); 43 | const action = actions.apiRejected(error, metaType); 44 | 45 | expect( 46 | reducer({ [metaType]: { inProgress: true, error: null } }, action)[ 47 | metaType 48 | ], 49 | ).toEqual({ 50 | error, 51 | inProgress: false, 52 | result: null, 53 | }); 54 | }); 55 | } 56 | }); 57 | -------------------------------------------------------------------------------- /frontend/src/reducers/api.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers, Reducer, Action } from 'redux'; 2 | import { handleActions } from 'redux-actions'; 3 | import { filterActions } from 'redux-ignore'; 4 | import * as actions from '../actions/api'; 5 | import * as types from '../actions/types'; 6 | import { IActionWithMeta, IApiState } from '../types/redux'; 7 | 8 | export const predicate = (action: IActionWithMeta, metaType: string) => { 9 | if (action.meta && action.meta.type === metaType) { 10 | return true; 11 | } 12 | return false; 13 | }; 14 | 15 | const initialState: IApiState = { 16 | error: null, 17 | inProgress: false, 18 | result: null, 19 | }; 20 | 21 | const apiSubReducer = handleActions( 22 | { 23 | [types.API_REQUESTED]: () => { 24 | const newState = { 25 | error: null, 26 | inProgress: true, 27 | result: null, 28 | }; 29 | return newState; 30 | }, 31 | [types.API_FULFILLED]: (_, action) => { 32 | const newState = { 33 | error: null, 34 | inProgress: false, 35 | result: action.payload, 36 | }; 37 | return newState; 38 | }, 39 | [types.API_REJECTED]: (_, action) => { 40 | const newState = { 41 | error: action.payload, 42 | inProgress: false, 43 | result: null, 44 | }; 45 | return newState; 46 | }, 47 | }, 48 | initialState, 49 | ) as Reducer; 50 | 51 | export let subReducers: { 52 | [metaType: string]: Reducer; 53 | } = {}; 54 | Object.keys(actions.apiMetaTypes).forEach(metaType => { 55 | subReducers[metaType] = filterActions(apiSubReducer, (action: Action) => 56 | predicate(action as IActionWithMeta, metaType), 57 | ); 58 | }); 59 | 60 | export default combineReducers(subReducers); 61 | -------------------------------------------------------------------------------- /frontend/src/reducers/auth.test.ts: -------------------------------------------------------------------------------- 1 | import * as actions from '../actions/auth'; 2 | import * as types from '../actions/types'; 3 | import reducer from './auth'; 4 | 5 | const state = { 6 | authenticated: false, 7 | error: null, 8 | inProgress: false, 9 | initialized: false, 10 | }; 11 | 12 | describe('Auth Reducer', () => { 13 | test(types.LOGIN_REQUESTED, () => { 14 | expect( 15 | reducer({ ...state, inProgress: false }, actions.loginRequested()), 16 | ).toEqual({ 17 | ...state, 18 | inProgress: true, 19 | }); 20 | }); 21 | 22 | test(types.LOGIN_FULFILLED, () => { 23 | expect( 24 | reducer( 25 | { 26 | ...state, 27 | authenticated: false, 28 | error: new Error('error'), 29 | inProgress: true, 30 | }, 31 | actions.loginFulfilled(), 32 | ), 33 | ).toEqual({ 34 | ...state, 35 | authenticated: true, 36 | error: null, 37 | inProgress: false, 38 | }); 39 | }); 40 | 41 | test(types.LOGIN_REJECTED, () => { 42 | const error = new Error('error'); 43 | expect( 44 | reducer( 45 | { 46 | ...state, 47 | authenticated: true, 48 | error: null, 49 | inProgress: true, 50 | }, 51 | actions.loginRejected(error), 52 | ), 53 | ).toEqual({ 54 | ...state, 55 | authenticated: false, 56 | error, 57 | inProgress: false, 58 | }); 59 | }); 60 | test(types.LOGOUT_REQUESTED, () => { 61 | expect( 62 | reducer({ ...state, inProgress: false }, actions.logoutRequested()), 63 | ).toEqual({ 64 | ...state, 65 | inProgress: true, 66 | }); 67 | }); 68 | 69 | test(types.LOGOUT_FULFILLED, () => { 70 | expect( 71 | reducer( 72 | { 73 | ...state, 74 | authenticated: true, 75 | error: new Error('error'), 76 | inProgress: true, 77 | }, 78 | actions.logoutFulfilled(), 79 | ), 80 | ).toEqual({ 81 | ...state, 82 | authenticated: false, 83 | error: null, 84 | inProgress: false, 85 | }); 86 | }); 87 | 88 | test(types.LOGOUT_FULFILLED, () => { 89 | const error = new Error('error'); 90 | expect( 91 | reducer( 92 | { 93 | ...state, 94 | authenticated: true, 95 | error: null, 96 | inProgress: true, 97 | }, 98 | actions.logoutRejected(error), 99 | ), 100 | ).toEqual({ 101 | ...state, 102 | authenticated: false, 103 | error, 104 | inProgress: false, 105 | }); 106 | }); 107 | 108 | test(types.SYNC_AUTH_STATE_REQUESTED, () => { 109 | expect( 110 | reducer( 111 | { ...state, inProgress: false }, 112 | actions.syncAuthStateRequested(), 113 | ), 114 | ).toEqual({ 115 | ...state, 116 | inProgress: true, 117 | }); 118 | }); 119 | 120 | test(types.SYNC_AUTH_STATE_FULFILLED, () => { 121 | const authenticated = true; 122 | expect( 123 | reducer( 124 | { 125 | ...state, 126 | authenticated: false, 127 | error: new Error('error'), 128 | inProgress: true, 129 | initialized: false, 130 | }, 131 | actions.syncAuthStateFulfilled(authenticated), 132 | ), 133 | ).toEqual({ 134 | ...state, 135 | authenticated, 136 | error: null, 137 | inProgress: false, 138 | initialized: true, 139 | }); 140 | }); 141 | 142 | test(types.SYNC_AUTH_STATE_REJECTED, () => { 143 | const error = new Error('error'); 144 | expect( 145 | reducer( 146 | { 147 | ...state, 148 | authenticated: true, 149 | error: null, 150 | inProgress: true, 151 | initialized: false, 152 | }, 153 | actions.syncAuthStateRejected(error), 154 | ), 155 | ).toEqual({ 156 | ...state, 157 | authenticated: false, 158 | error, 159 | inProgress: false, 160 | initialized: true, 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /frontend/src/reducers/auth.ts: -------------------------------------------------------------------------------- 1 | import { Action, handleActions } from 'redux-actions'; 2 | import * as types from '../actions/types'; 3 | import { IAuthState } from '../types/redux'; 4 | 5 | const defaultState: IAuthState = { 6 | authenticated: false, 7 | error: null, 8 | inProgress: false, 9 | initialized: false, 10 | }; 11 | 12 | const reducer = handleActions( 13 | { 14 | [types.LOGIN_REQUESTED]: state => { 15 | return { ...state, inProgress: true }; 16 | }, 17 | [types.LOGIN_FULFILLED]: state => { 18 | return { ...state, inProgress: false, authenticated: true, error: null }; 19 | }, 20 | [types.LOGIN_REJECTED]: (state, action) => { 21 | return { 22 | ...state, 23 | authenticated: false, 24 | error: action.payload, 25 | inProgress: false, 26 | }; 27 | }, 28 | [types.LOGOUT_REQUESTED]: state => { 29 | return { ...state, inProgress: true }; 30 | }, 31 | [types.LOGOUT_FULFILLED]: state => { 32 | return { ...state, inProgress: false, authenticated: false, error: null }; 33 | }, 34 | [types.LOGOUT_REJECTED]: (state, action: Action) => { 35 | return { 36 | ...state, 37 | authenticated: false, 38 | error: action.payload, 39 | inProgress: false, 40 | }; 41 | }, 42 | [types.SYNC_AUTH_STATE_REQUESTED]: state => { 43 | return { ...state, inProgress: true }; 44 | }, 45 | [types.SYNC_AUTH_STATE_FULFILLED]: (_, action: Action) => { 46 | return { 47 | authenticated: !!action.payload, 48 | error: null, 49 | inProgress: false, 50 | initialized: true, 51 | }; 52 | }, 53 | [types.SYNC_AUTH_STATE_REJECTED]: (_, action: Action) => { 54 | return { 55 | authenticated: false, 56 | error: action.payload, 57 | inProgress: false, 58 | initialized: true, 59 | }; 60 | }, 61 | }, 62 | defaultState, 63 | ); 64 | 65 | export default reducer; 66 | -------------------------------------------------------------------------------- /frontend/src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | // istanbul ignore file 2 | import { combineReducers } from 'redux'; 3 | import api from './api'; 4 | import auth from './auth'; 5 | 6 | const rootReducer = combineReducers({ 7 | api, 8 | auth, 9 | }); 10 | 11 | export default rootReducer; 12 | -------------------------------------------------------------------------------- /frontend/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { push } from 'connected-react-router'; 2 | import * as React from 'react'; 3 | import Loadable from 'react-loadable'; 4 | import { Route } from 'react-router'; 5 | import locationHelperBuilder from 'redux-auth-wrapper/history4/locationHelper'; 6 | import { connectedReduxRedirect } from 'redux-auth-wrapper/history4/redirect'; 7 | import Loading from '../components/Loading'; 8 | import { ComponentLoading } from '../components/Loading'; 9 | import { isAuthenticated, isNotAuthenticated } from '../selectors/auth'; 10 | 11 | export const paths = { 12 | callback: '/callback', 13 | home: '/', 14 | login: '/login', 15 | private: '/private', 16 | public: '/public', 17 | }; 18 | 19 | export const userIsAuthenticated = connectedReduxRedirect({ 20 | AuthenticatingComponent: Loading, 21 | // If selector is true, wrapper will not redirect 22 | // For example let's check that state contains user data 23 | authenticatedSelector: isAuthenticated, 24 | redirectAction: push, 25 | // The url to redirect user to if they fail 26 | redirectPath: paths.login, 27 | // A nice display name for this check 28 | wrapperDisplayName: 'UserIsAuthenticated', 29 | }); 30 | 31 | const locationHelper = locationHelperBuilder({}); 32 | 33 | export const userIsNotAuthenticated = connectedReduxRedirect({ 34 | // This prevents us from adding the query parameter when we send the user away from the login page 35 | allowRedirectBack: false, 36 | // If selector is true, wrapper will not redirect 37 | // So if there is no user data, then we show the page 38 | authenticatedSelector: isNotAuthenticated, 39 | redirectAction: push, 40 | // This sends the user either to the query param route if we have one, or to the landing page if none is specified and the user is already logged in 41 | redirectPath: (_, ownProps) => 42 | locationHelper.getRedirectQueryParam(ownProps) || paths.home, 43 | // A nice display name for this check 44 | wrapperDisplayName: 'UserIsNotAuthenticated', 45 | }); 46 | 47 | export const routes = { 48 | Callback: ( 49 | import('../components/Callback'), 53 | loading: ComponentLoading, 54 | })} 55 | /> 56 | ), 57 | Home: ( 58 | import('../components/Home'), 63 | loading: ComponentLoading, 64 | })} 65 | /> 66 | ), 67 | Login: ( 68 | import('../components/Login'), 73 | loading: ComponentLoading, 74 | }), 75 | )} 76 | /> 77 | ), 78 | NotFound: ( 79 | import('../components/NotFound'), 82 | loading: ComponentLoading, 83 | })} 84 | /> 85 | ), 86 | Private: ( 87 | import('../components/Private'), 92 | loading: ComponentLoading, 93 | }), 94 | )} 95 | /> 96 | ), 97 | Public: ( 98 | import('../components/Public'), 102 | loading: ComponentLoading, 103 | })} 104 | /> 105 | ), 106 | }; 107 | -------------------------------------------------------------------------------- /frontend/src/sagas/api.test.ts: -------------------------------------------------------------------------------- 1 | import { call, put, takeEvery } from 'redux-saga/effects'; 2 | import { cloneableGenerator } from 'redux-saga/utils'; 3 | import * as actions from '../actions/api'; 4 | import * as types from '../actions/types'; 5 | import { emailApi, privateApi, publicApi } from '../lib/api'; 6 | import * as sagas from './api'; 7 | 8 | describe('api sagas', () => { 9 | test('getHandler', () => { 10 | expect(sagas.getHandler(actions.apiMetaTypes.email)).toBe(emailApi); 11 | expect(sagas.getHandler(actions.apiMetaTypes.private)).toBe(privateApi); 12 | expect(sagas.getHandler(actions.apiMetaTypes.public)).toBe(publicApi); 13 | }); 14 | 15 | test('handleApi', () => { 16 | const toAddress = 'toAddress'; 17 | const action = actions.emailApiRequested(toAddress); 18 | const generator = cloneableGenerator(sagas.handleApi)(action); 19 | 20 | expect(generator.next().value).toEqual( 21 | call(sagas.getHandler, action.meta.type), 22 | ); 23 | 24 | const errorGenerator = generator.clone(); 25 | const error = new Error('Some Error'); 26 | expect(errorGenerator.throw && errorGenerator.throw(error).value).toEqual( 27 | put(actions.apiRejected(error, action.meta.type)), 28 | ); 29 | expect(errorGenerator.next().done).toBe(true); 30 | 31 | const handler = jest.fn(); 32 | expect(generator.next(handler).value).toEqual(call(handler, toAddress)); 33 | 34 | const result = 'result'; 35 | expect(generator.next(result).value).toEqual( 36 | put(actions.apiFulfilled(result, action.meta.type)), 37 | ); 38 | expect(generator.next().done).toBe(true); 39 | }); 40 | 41 | test('apiRequested', () => { 42 | const generator = sagas.apiRequested(); 43 | expect(generator.next().value).toEqual( 44 | takeEvery(types.API_REQUESTED, sagas.handleApi), 45 | ); 46 | expect(generator.next().done).toBe(true); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /frontend/src/sagas/api.ts: -------------------------------------------------------------------------------- 1 | import { call, put, takeEvery } from 'redux-saga/effects'; 2 | import * as actions from '../actions/api'; 3 | import * as types from '../actions/types'; 4 | import { 5 | createTodosItem, 6 | emailApi, 7 | privateApi, 8 | publicApi, 9 | saveFile, 10 | } from '../lib/api'; 11 | import { IActionWithMeta } from '../types/redux'; 12 | 13 | const handlers = { 14 | [actions.apiMetaTypes.email]: emailApi, 15 | [actions.apiMetaTypes.private]: privateApi, 16 | [actions.apiMetaTypes.public]: publicApi, 17 | [actions.apiMetaTypes.file]: saveFile, 18 | [actions.apiMetaTypes.dbCreate]: createTodosItem, 19 | }; 20 | 21 | export const getHandler = (type: string) => { 22 | return handlers[type]; 23 | }; 24 | 25 | export function* handleApi(action: IActionWithMeta) { 26 | const { payload, meta } = action; 27 | const { type } = meta; 28 | 29 | try { 30 | const handler = yield call(getHandler, type); 31 | const result = yield call(handler, payload); 32 | yield put(actions.apiFulfilled(result, type)); 33 | } catch (e) { 34 | yield put(actions.apiRejected(e, type)); 35 | } 36 | } 37 | 38 | export function* apiRequested() { 39 | yield takeEvery(types.API_REQUESTED, handleApi); 40 | } 41 | 42 | const sagas = [apiRequested()]; 43 | 44 | export default sagas; 45 | -------------------------------------------------------------------------------- /frontend/src/sagas/auth.test.ts: -------------------------------------------------------------------------------- 1 | import { call, put, select, takeEvery } from 'redux-saga/effects'; 2 | import { cloneableGenerator } from 'redux-saga/utils'; 3 | import * as types from '../actions/types'; 4 | import { isAuthenticated, login, logout } from '../lib/auth'; 5 | import { 6 | logoutFulfilled, 7 | logoutRejected, 8 | syncAuthStateFulfilled, 9 | syncAuthStateRejected, 10 | } from './../actions/auth'; 11 | import { redirectRouteSelector } from './../selectors/router'; 12 | import * as sagas from './auth'; 13 | 14 | describe('Auth Sagas', () => { 15 | test('should invoke login on handleLogin', () => { 16 | const generator = sagas.handleLogin(); 17 | expect(generator.next().value).toEqual(select(redirectRouteSelector)); 18 | 19 | const route = 'router'; 20 | expect(generator.next(route).value).toEqual(call(login, route)); 21 | expect(generator.next().done).toBe(true); 22 | }); 23 | 24 | test('should handleLogin on loginSaga', () => { 25 | const generator = sagas.loginSaga(); 26 | expect(generator.next().value).toEqual( 27 | takeEvery(types.LOGIN_REQUESTED, sagas.handleLogin), 28 | ); 29 | expect(generator.next().done).toBe(true); 30 | }); 31 | 32 | test('should invoke logout on handleLogout', () => { 33 | const generator = cloneableGenerator(sagas.handleLogout)(); 34 | 35 | expect(generator.next().value).toEqual(call(logout)); 36 | 37 | const errorGenerator = generator.clone(); 38 | const error = new Error('logout error'); 39 | expect(errorGenerator.throw && errorGenerator.throw(error).value).toEqual( 40 | put(logoutRejected(error)), 41 | ); 42 | expect(errorGenerator.next().done).toBe(true); 43 | 44 | expect(generator.next().value).toEqual(put(logoutFulfilled())); 45 | expect(generator.next().done).toBe(true); 46 | }); 47 | 48 | test('should handleLogout on logoutSaga', () => { 49 | const generator = sagas.logoutSaga(); 50 | expect(generator.next().value).toEqual( 51 | takeEvery(types.LOGOUT_REQUESTED, sagas.handleLogout), 52 | ); 53 | expect(generator.next().done).toBe(true); 54 | }); 55 | 56 | test('should invoke isAuthenticated on handleAuthState', () => { 57 | const generator = cloneableGenerator(sagas.handleAuthState)(); 58 | 59 | expect(generator.next().value).toEqual(call(isAuthenticated)); 60 | 61 | const errorGenerator = generator.clone(); 62 | const error = new Error('isAuthenticated error'); 63 | expect(errorGenerator.throw && errorGenerator.throw(error).value).toEqual( 64 | put(syncAuthStateRejected(error)), 65 | ); 66 | expect(errorGenerator.next().done).toBe(true); 67 | 68 | const authenticated = true; 69 | expect(generator.next(authenticated).value).toEqual( 70 | put(syncAuthStateFulfilled(authenticated)), 71 | ); 72 | expect(generator.next().done).toBe(true); 73 | }); 74 | 75 | test('should handleAuthState on authStateSaga', () => { 76 | const generator = sagas.authStateSaga(); 77 | expect(generator.next().value).toEqual( 78 | takeEvery(types.SYNC_AUTH_STATE_REQUESTED, sagas.handleAuthState), 79 | ); 80 | expect(generator.next().done).toBe(true); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /frontend/src/sagas/auth.ts: -------------------------------------------------------------------------------- 1 | import { call, put, select, takeEvery } from 'redux-saga/effects'; 2 | import * as types from '../actions/types'; 3 | import { isAuthenticated, login, logout } from '../lib/auth'; 4 | import { 5 | logoutFulfilled, 6 | logoutRejected, 7 | syncAuthStateFulfilled, 8 | syncAuthStateRejected, 9 | } from './../actions/auth'; 10 | import { redirectRouteSelector } from './../selectors/router'; 11 | 12 | export function* handleLogin() { 13 | const redirectRoute = yield select(redirectRouteSelector); 14 | yield call(login, redirectRoute); 15 | } 16 | 17 | export function* loginSaga() { 18 | yield takeEvery(types.LOGIN_REQUESTED, handleLogin); 19 | } 20 | 21 | export function* handleLogout() { 22 | try { 23 | yield call(logout); 24 | yield put(logoutFulfilled()); 25 | } catch (err) { 26 | yield put(logoutRejected(err)); 27 | } 28 | } 29 | 30 | export function* logoutSaga() { 31 | yield takeEvery(types.LOGOUT_REQUESTED, handleLogout); 32 | } 33 | 34 | export function* handleAuthState() { 35 | try { 36 | const authenticated = yield call(isAuthenticated); 37 | yield put(syncAuthStateFulfilled(authenticated)); 38 | } catch (err) { 39 | yield put(syncAuthStateRejected(err)); 40 | } 41 | } 42 | 43 | export function* authStateSaga() { 44 | yield takeEvery(types.SYNC_AUTH_STATE_REQUESTED, handleAuthState); 45 | } 46 | 47 | const sagas = [loginSaga(), logoutSaga(), authStateSaga()]; 48 | 49 | export default sagas; 50 | -------------------------------------------------------------------------------- /frontend/src/sagas/index.test.ts: -------------------------------------------------------------------------------- 1 | import { all } from 'redux-saga/effects'; 2 | import root from './index'; 3 | 4 | jest.mock('./api', () => ['api']); 5 | 6 | jest.mock('./auth', () => ['auth']); 7 | 8 | describe('Sagas Index', () => { 9 | test('should export all sagas', () => { 10 | const generator = root(); 11 | expect(generator.next().value).toEqual(all(['api', 'auth'])); 12 | expect(generator.next().done).toBe(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /frontend/src/sagas/index.ts: -------------------------------------------------------------------------------- 1 | import { all } from 'redux-saga/effects'; 2 | import api from './api'; 3 | import auth from './auth'; 4 | 5 | export const sagas = [...api, ...auth]; 6 | 7 | export default function* root() { 8 | yield all(sagas); 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/selectors/api.test.ts: -------------------------------------------------------------------------------- 1 | import { apiMetaTypes } from './../actions/api'; 2 | import * as selectors from './api'; 3 | 4 | describe('Api Selectors', () => { 5 | test('should return api state on apiStateSelector', () => { 6 | const subState = { inProgress: false, error: null, result: 'result' }; 7 | expect( 8 | // @ts-ignore missing properties for state 9 | selectors.apiStateSelector({ api: { apiType: subState } }, 'apiType'), 10 | ).toEqual(subState); 11 | }); 12 | 13 | Object.keys(apiMetaTypes).forEach(key => { 14 | test(`should return ${key} state on selector`, () => { 15 | const subState = { inProgress: false, error: null, result: 'result' }; 16 | expect( 17 | // @ts-ignore missing properties for state 18 | selectors[`${key}ApiSelector`]({ api: { [key]: subState } }), 19 | ).toEqual(subState); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /frontend/src/selectors/api.ts: -------------------------------------------------------------------------------- 1 | import { apiMetaTypes } from '../actions/api'; 2 | import { IState } from '../types/redux'; 3 | 4 | export const apiStateSelector = (state: IState, type: string) => 5 | state.api[type]; 6 | 7 | export const emailApiSelector = (state: IState) => 8 | apiStateSelector(state, apiMetaTypes.email); 9 | 10 | export const privateApiSelector = (state: IState) => 11 | apiStateSelector(state, apiMetaTypes.private); 12 | 13 | export const publicApiSelector = (state: IState) => 14 | apiStateSelector(state, apiMetaTypes.public); 15 | 16 | export const fileApiSelector = (state: IState) => 17 | apiStateSelector(state, apiMetaTypes.file); 18 | 19 | export const dbCreateApiSelector = (state: IState) => 20 | apiStateSelector(state, apiMetaTypes.dbCreate); 21 | -------------------------------------------------------------------------------- /frontend/src/selectors/auth.test.ts: -------------------------------------------------------------------------------- 1 | import * as selectors from './auth'; 2 | 3 | describe('Auth Selectors', () => { 4 | test('should return auth flag on isAuthenticated', () => { 5 | expect( 6 | // @ts-ignore missing properties for state 7 | selectors.isAuthenticated({ auth: { authenticated: false } }), 8 | ).toEqual(false); 9 | expect( 10 | // @ts-ignore missing properties for state 11 | selectors.isAuthenticated({ auth: { authenticated: true } }), 12 | ).toEqual(true); 13 | }); 14 | 15 | test('should negate auth flag on isNotAuthenticated', () => { 16 | expect( 17 | // @ts-ignore missing properties for state 18 | selectors.isNotAuthenticated({ auth: { authenticated: false } }), 19 | ).toEqual(true); 20 | expect( 21 | // @ts-ignore missing properties for state 22 | selectors.isNotAuthenticated({ auth: { authenticated: true } }), 23 | ).toEqual(false); 24 | }); 25 | 26 | test('should return initialized flag on isInitialized', () => { 27 | expect( 28 | // @ts-ignore missing properties for state 29 | selectors.isInitialized({ auth: { initialized: false } }), 30 | ).toEqual(false); 31 | expect( 32 | // @ts-ignore missing properties for state 33 | selectors.isInitialized({ auth: { initialized: true } }), 34 | ).toEqual(true); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /frontend/src/selectors/auth.ts: -------------------------------------------------------------------------------- 1 | import { IState } from '../types/redux'; 2 | 3 | export const isAuthenticated = (state: IState) => state.auth.authenticated; 4 | export const isNotAuthenticated = (state: IState) => !state.auth.authenticated; 5 | export const isInitialized = (state: IState) => state.auth.initialized; 6 | -------------------------------------------------------------------------------- /frontend/src/selectors/router.test.ts: -------------------------------------------------------------------------------- 1 | import { paths } from '../routes'; 2 | import * as selectors from './router'; 3 | 4 | describe('Router Selectors', () => { 5 | test('should return pathname', () => { 6 | expect( 7 | // @ts-ignore missing properties for state 8 | selectors.pathnameSelector({ router: { location: { pathname: '' } } }), 9 | ).toEqual(paths.home); 10 | expect( 11 | selectors.pathnameSelector({ 12 | // @ts-ignore missing properties for state 13 | router: { location: { pathname: '/somepath' } }, 14 | }), 15 | ).toEqual('/somepath'); 16 | }); 17 | 18 | test('should return redirect query parameter', () => { 19 | expect( 20 | selectors.redirectRouteSelector({ 21 | // @ts-ignore missing properties for state 22 | router: { location: { search: '?' } }, 23 | }), 24 | ).toEqual(null); 25 | expect( 26 | selectors.redirectRouteSelector({ 27 | // @ts-ignore missing properties for state 28 | router: { location: { search: '?redirect=login' } }, 29 | }), 30 | ).toEqual('login'); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /frontend/src/selectors/router.ts: -------------------------------------------------------------------------------- 1 | import { paths } from '../routes'; 2 | import { IState } from '../types/redux'; 3 | 4 | export const pathnameSelector = (state: IState) => 5 | state.router.location.pathname || paths.home; 6 | 7 | export const redirectRouteSelector = (state: IState) => { 8 | const search = state.router.location.search; 9 | const params = new URLSearchParams(search); 10 | return params.get('redirect'); 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | import 'jest-styled-components'; 4 | 5 | Enzyme.configure({ adapter: new Adapter() }); 6 | -------------------------------------------------------------------------------- /frontend/src/types/redux.ts: -------------------------------------------------------------------------------- 1 | import { RouterState } from 'connected-react-router'; 2 | import { ActionMeta } from 'redux-actions'; 3 | 4 | interface IMeta { 5 | type: string; 6 | } 7 | 8 | export interface IActionWithMeta extends ActionMeta {} 9 | 10 | export interface IAuthState { 11 | authenticated: boolean; 12 | error: Error | null; 13 | inProgress: boolean; 14 | initialized: boolean; 15 | } 16 | 17 | export interface IRouterState extends RouterState { 18 | location: { 19 | pathname: string; 20 | search: string; 21 | hash: string; 22 | key: string; 23 | state: { from: string }; 24 | }; 25 | } 26 | 27 | export interface IApiState { 28 | inProgress: boolean; 29 | result: any; 30 | error: Error | null; 31 | } 32 | 33 | export interface IState { 34 | auth: IAuthState; 35 | router: IRouterState; 36 | api: { [key: string]: IApiState }; 37 | } 38 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /frontend/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"], 3 | "linterOptions": { 4 | "exclude": ["config/**/*.js", "node_modules/**/*.ts"] 5 | }, 6 | "rules": { 7 | "no-unused-expression": [true, "allow-fast-null-checks"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "23.2.0", 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "packages": [ 6 | "services/*", 7 | "frontend" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "services/*", 5 | "frontend" 6 | ], 7 | "scripts": { 8 | "build:frontend": "lerna run build --scope frontend", 9 | "lint": "lerna exec yarn lint", 10 | "test": "lerna exec yarn test", 11 | "test:ci": "lerna exec --concurrency 1 yarn test:ci", 12 | "test:e2e": "lerna run --concurrency 1 test:e2e", 13 | "coverage": "lerna exec --concurrency 1 yarn coverage", 14 | "deploy:dev": "lerna run --concurrency 1 deploy --ignore 'frontend' -- --stage dev && lerna run deploy --scope frontend -- --stage dev", 15 | "deploy:qa": "lerna run --concurrency 1 deploy --ignore 'frontend' -- --stage qa && lerna run deploy --scope frontend -- --stage qa", 16 | "deploy:prod": "lerna run --concurrency 1 deploy --ignore 'frontend' -- --stage prod && lerna run deploy --scope frontend -- --stage prod", 17 | "publish:frontend:dev": "lerna run publish --scope frontend -- --stage dev", 18 | "publish:frontend:qa": "lerna run publish --scope frontend -- --stage qa", 19 | "publish:frontend:prod": "lerna run publish --scope frontend -- --stage prod", 20 | "run:all:dev": "yarn deploy:dev && yarn build:frontend && yarn publish:frontend:dev", 21 | "run:all:qa": "yarn deploy:qa && yarn build:frontend && yarn publish:frontend:qa", 22 | "run:all:prod": "yarn deploy:prod && yarn build:frontend && yarn publish:frontend:prod", 23 | "remove:all:dev": "lerna run --concurrency 1 remove --scope 'frontend' -- --stage dev && lerna run --concurrency 1 remove --ignore 'frontend' -- --stage dev", 24 | "remove:all:qa": "lerna run --concurrency 1 remove --scope 'frontend' -- --stage qa && lerna run --concurrency 1 remove --ignore 'frontend' -- --stage qa", 25 | "remove:all:prod": "lerna run --concurrency 1 remove --scope 'frontend' -- --stage prod && lerna run --concurrency 1 remove --ignore 'frontend' -- --stage prod", 26 | "prettier": "lerna exec yarn prettier", 27 | "prettier:ci": "lerna exec yarn prettier:ci" 28 | }, 29 | "devDependencies": { 30 | "jest-junit": "^13.0.0", 31 | "lerna": "^4.0.0", 32 | "prettier": "^2.0.0" 33 | }, 34 | "author": "Erez Rokah", 35 | "license": "MIT", 36 | "name": "serverless-monorepo-app", 37 | "resolutions": { 38 | "@types/react": "17.0.86" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>erezrokah/shared-configurations:renovate-default"] 3 | } 4 | -------------------------------------------------------------------------------- /scripts/stackOutput.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs-extra'); 3 | const dotenv = require('dotenv'); 4 | const os = require('os'); 5 | 6 | const replaceInEnvFile = async (file, envs) => { 7 | const keys = Object.keys(envs); 8 | if (keys.length <= 0) { 9 | return; 10 | } 11 | 12 | const envFile = path.join(__dirname, '../frontend', file); 13 | await fs.ensureFile(envFile); 14 | const content = await fs.readFile(envFile); 15 | const envConfig = await dotenv.parse(content); 16 | 17 | keys.forEach(key => { 18 | envConfig[key] = envs[key]; 19 | }); 20 | 21 | await fs.remove(envFile); 22 | await Promise.all( 23 | Object.keys(envConfig).map(key => 24 | fs.appendFile(envFile, `${key}=${envConfig[key]}${os.EOL}`), 25 | ), 26 | ); 27 | }; 28 | 29 | const handler = async (data, serverless, _) => { 30 | //this handler creates the environment for the frontend based on the services deployment output 31 | const { ServiceEndpoint, WebAppCloudFrontDistributionOutput } = data; 32 | 33 | if (WebAppCloudFrontDistributionOutput) { 34 | await replaceInEnvFile('.env.production.local', { 35 | REACT_APP_AUTH0_REDIRECT_URI: `https://${WebAppCloudFrontDistributionOutput}/callback`, 36 | }); 37 | } 38 | 39 | if (ServiceEndpoint) { 40 | const serviceName = serverless.service.serviceObject.name 41 | .replace(/\-/g, '_') 42 | .toUpperCase(); 43 | 44 | await replaceInEnvFile('.env.local', { 45 | [`REACT_APP_${serviceName}_ENDPOINT`]: ServiceEndpoint, 46 | }); 47 | } 48 | }; 49 | 50 | module.exports = { handler }; 51 | -------------------------------------------------------------------------------- /services/api-service/.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless 7 | 8 | # Webpack directories 9 | .webpack 10 | 11 | config.*.json 12 | !config.example.json 13 | 14 | coverage 15 | 16 | junit.xml 17 | 18 | out.txt 19 | 20 | e2e/config.ts -------------------------------------------------------------------------------- /services/api-service/README.md: -------------------------------------------------------------------------------- 1 | # Api Service 2 | 3 | ## Setup 4 | 5 | ```bash 6 | yarn install 7 | ``` 8 | -------------------------------------------------------------------------------- /services/api-service/e2e/config.example.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | api: 'https://api-id.execute-api.us-east-1.amazonaws.com/dev/api', 3 | clientId: '*************************', 4 | domain: 'my-domain.auth0.com', 5 | email: 'test@email.com', 6 | managementClientId: '*************************', 7 | managementClientSecret: '*************************', 8 | password: '*******************', 9 | redirectUri: 'http://localhost:5000/callback', 10 | }; 11 | -------------------------------------------------------------------------------- /services/api-service/e2e/privateEndpoint.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { config } from './config'; 3 | import { deleteUserByEmail } from './utils/auth0'; 4 | import { auth0Login } from './utils/puppeteer'; 5 | 6 | describe('private api e2e', () => { 7 | const url = `${config.api}/private`; 8 | const method = 'POST'; 9 | 10 | describe('unauthorized tests', () => { 11 | test('should return 401 response', async () => { 12 | expect.assertions(1); 13 | await expect({ url, method }).toReturnResponse({ 14 | data: { 15 | message: 'Unauthorized', 16 | }, 17 | statusCode: 401, 18 | }); 19 | }); 20 | }); 21 | 22 | describe('authorized tests', () => { 23 | const { 24 | domain, 25 | clientId, 26 | email, 27 | password, 28 | redirectUri, 29 | managementClientId, 30 | managementClientSecret, 31 | } = config; 32 | 33 | let authorization = ''; 34 | 35 | beforeEach(async () => { 36 | // delete existing test user 37 | await deleteUserByEmail( 38 | email, 39 | domain, 40 | managementClientId, 41 | managementClientSecret, 42 | ); 43 | 44 | // login and get token 45 | authorization = await auth0Login( 46 | domain, 47 | clientId, 48 | email, 49 | password, 50 | redirectUri, 51 | ); 52 | }); 53 | 54 | afterEach(async () => { 55 | // delete existing test user 56 | await deleteUserByEmail( 57 | email, 58 | domain, 59 | managementClientId, 60 | managementClientSecret, 61 | ); 62 | }); 63 | 64 | test('should return 200 response on authorized request', async () => { 65 | expect.assertions(1); 66 | 67 | await expect({ 68 | headers: { Authorization: authorization }, 69 | method, 70 | url, 71 | }).toReturnResponse({ 72 | data: { 73 | message: 74 | 'Hi ⊂◉‿◉つ from Private API. Only logged in users can see this', 75 | }, 76 | statusCode: 200, 77 | }); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /services/api-service/e2e/publicEndpoint.chai.test.ts: -------------------------------------------------------------------------------- 1 | import awsTesting from 'aws-testing-library/lib/chai'; 2 | import chai = require('chai'); 3 | 4 | chai.use(awsTesting); 5 | 6 | const { expect } = chai; 7 | 8 | describe('public api e2e', () => { 9 | const { config } = require('./config'); 10 | const url = `${config.api}/public`; 11 | const method = 'POST'; 12 | 13 | test('should return 200OK response', async () => { 14 | await expect({ url, method }).to.have.response({ 15 | data: { 16 | message: 'Hi ⊂◉‿◉つ from Public API', 17 | }, 18 | statusCode: 200, 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /services/api-service/e2e/publicEndpoint.test.ts: -------------------------------------------------------------------------------- 1 | describe('public api e2e', () => { 2 | const { config } = require('./config'); 3 | const url = `${config.api}/public`; 4 | const method = 'POST'; 5 | 6 | test('should return 200OK response', async () => { 7 | expect.assertions(1); 8 | await expect({ url, method }).toReturnResponse({ 9 | data: { 10 | message: 'Hi ⊂◉‿◉つ from Public API', 11 | }, 12 | statusCode: 200, 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /services/api-service/e2e/setup.ts: -------------------------------------------------------------------------------- 1 | import { deploy } from 'aws-testing-library/lib/utils/serverless'; 2 | 3 | const deployDev = async () => { 4 | await deploy('dev'); 5 | }; 6 | 7 | export default deployDev; 8 | -------------------------------------------------------------------------------- /services/api-service/e2e/setupFrameworks.ts: -------------------------------------------------------------------------------- 1 | import 'aws-testing-library/lib/jest'; 2 | -------------------------------------------------------------------------------- /services/api-service/e2e/setup_hook.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register'); 2 | module.exports = require('./setup').default; 3 | -------------------------------------------------------------------------------- /services/api-service/e2e/utils/auth0.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export interface IConfig { 4 | domain: string; 5 | clientId: string; 6 | clientSecret: string; 7 | } 8 | 9 | interface IUser { 10 | email: string; 11 | user_id: string; 12 | } 13 | 14 | const getAuthorization = async ( 15 | domain: string, 16 | clientId: string, 17 | clientSecret: string, 18 | ) => { 19 | const response = await axios({ 20 | data: JSON.stringify({ 21 | audience: `https://${domain}/api/v2/`, 22 | client_id: clientId, 23 | client_secret: clientSecret, 24 | grant_type: 'client_credentials', 25 | }), 26 | headers: { 'content-type': 'application/json' }, 27 | method: 'POST', 28 | url: `https://${domain}/oauth/token`, 29 | }); 30 | const { token_type, access_token } = response.data; 31 | return `${token_type} ${access_token}`; 32 | }; 33 | 34 | const getUsers = async (authorization: string, domain: string) => { 35 | const response = await axios({ 36 | headers: { Authorization: authorization }, 37 | method: 'GET', 38 | url: `https://${domain}/api/v2/users`, 39 | }); 40 | return response.data as IUser[]; 41 | }; 42 | 43 | const deleteUser = async ( 44 | authorization: string, 45 | domain: string, 46 | user: IUser, 47 | ) => { 48 | await axios({ 49 | headers: { Authorization: authorization }, 50 | method: 'DELETE', 51 | url: `https://${domain}/api/v2/users/${user.user_id}`, 52 | }); 53 | }; 54 | 55 | export const deleteUserByEmail = async ( 56 | email: string, 57 | domain: string, 58 | clientId: string, 59 | clientSecret: string, 60 | ) => { 61 | const authorization = await getAuthorization(domain, clientId, clientSecret); 62 | const users = await getUsers(authorization, domain); 63 | // https://community.auth0.com/t/creating-a-user-converts-email-to-lowercase/6678 64 | const toDelete = users.filter(u => u.email === email.toLowerCase())[0]; 65 | if (toDelete) { 66 | await deleteUser(authorization, domain, toDelete); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /services/api-service/e2e/utils/puppeteer.ts: -------------------------------------------------------------------------------- 1 | import * as puppeteer from 'puppeteer'; 2 | import { URLSearchParams } from 'url'; 3 | 4 | const createNonce = (length: number) => { 5 | let text = ''; 6 | const possible = 7 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 8 | for (let i = 0; i < length; i++) { 9 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 10 | } 11 | return text; 12 | }; 13 | 14 | export const auth0Login = async ( 15 | domain: string, 16 | clientId: string, 17 | email: string, 18 | password: string, 19 | redirectUri: string, 20 | ) => { 21 | const browser = await puppeteer.launch(); 22 | 23 | const [page] = await browser.pages(); 24 | 25 | // urls hold redirect chain 26 | const urls = [] as string[]; 27 | 28 | // Get all redirects 29 | page.on('response', response => { 30 | const status = response.status(); 31 | if (status >= 300 && status <= 399) { 32 | urls.push(response.headers()['location']); 33 | } 34 | }); 35 | 36 | const nonce = createNonce(32); 37 | 38 | await page.goto( 39 | `https://${domain}/authorize?client_id=${clientId}&response_type=id_token token&nonce=${nonce}&redirect_uri=${redirectUri}`, 40 | { waitUntil: 'networkidle2' }, 41 | ); 42 | 43 | await page.waitForXPath("//a[contains(text(), 'Sign Up')]", { 44 | timeout: 5000, 45 | visible: true, 46 | }); 47 | const signUp = await page.$x("//a[contains(text(), 'Sign Up')]"); 48 | await signUp[0].click(); 49 | 50 | await page.waitForSelector('input[name="email"]', { 51 | timeout: 5000, 52 | visible: true, 53 | }); 54 | await page.type('input[name="email"]', email); 55 | await page.type('input[name="password"]', password); 56 | await page.click('button[type="submit"]'); 57 | 58 | await page.waitForSelector('button[id="allow"]', { 59 | timeout: 5000, 60 | visible: true, 61 | }); 62 | await page.click('button[id="allow"]'); 63 | await page.waitForNavigation({ waitUntil: 'networkidle2' }); 64 | 65 | const clientUrl = urls.filter(u => u.startsWith(redirectUri))[0]; 66 | let token = ''; 67 | if (clientUrl) { 68 | const hashQuery = clientUrl.substring( 69 | redirectUri.length + '/'.length + '#'.length, 70 | ); 71 | const params = new URLSearchParams(hashQuery); 72 | token = params.get('id_token') || ''; 73 | console.log('Login success, token:', token); 74 | } else { 75 | console.error('Login failed. Current url:', clientUrl); 76 | } 77 | 78 | await browser.close(); 79 | return `Bearer ${token}`; 80 | }; 81 | -------------------------------------------------------------------------------- /services/api-service/jest.config.e2e.js: -------------------------------------------------------------------------------- 1 | const config = require('./jest.config'); 2 | 3 | module.exports = { 4 | ...config, 5 | roots: ['/e2e'], 6 | globalSetup: '/e2e/setup_hook.js', 7 | setupTestFrameworkScriptFile: '/e2e/setupFrameworks.ts', 8 | }; 9 | -------------------------------------------------------------------------------- /services/api-service/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | reporters: ['default', 'jest-junit'], 9 | testEnvironment: 'node', 10 | }; 11 | -------------------------------------------------------------------------------- /services/api-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-service", 3 | "version": "0.0.1", 4 | "description": "Serverless Auth0 Service", 5 | "scripts": { 6 | "lint": "tslint 'src/**/*.ts'", 7 | "test": "jest", 8 | "test:watch": "jest --watch", 9 | "test:ci": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-test-results.xml jest --runInBand --ci", 10 | "test:e2e": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-e2e-test-results.xml jest --config=jest.config.e2e.js --runInBand --ci", 11 | "coverage": "jest --coverage", 12 | "deploy": "serverless deploy", 13 | "remove": "serverless remove", 14 | "prettier": "prettier --write src/**/*.ts", 15 | "prettier:ci": "prettier --list-different src/**/*.ts" 16 | }, 17 | "dependencies": { 18 | "jsonwebtoken": "^8.5.1" 19 | }, 20 | "devDependencies": { 21 | "@anttiviljami/serverless-stack-output": "^0.3.1", 22 | "@types/aws-lambda": "^8.10.62", 23 | "@types/chai": "^4.2.12", 24 | "@types/jest": "^27.0.0", 25 | "@types/jsonwebtoken": "^8.5.0", 26 | "@types/node": "^16.0.0", 27 | "@types/puppeteer": "^5.0.0", 28 | "aws-testing-library": "^2.0.0", 29 | "axios": "^1.0.0", 30 | "chai": "^4.2.0", 31 | "jest": "27.5.1", 32 | "puppeteer": "^13.0.0", 33 | "serverless-plugin-tracing": "^2.0.0", 34 | "serverless-webpack": "^5.2.0", 35 | "ts-jest": "27.1.5", 36 | "ts-loader": "^9.0.0", 37 | "tslint": "^6.0.0", 38 | "tslint-config-prettier": "^1.18.0", 39 | "typescript": "^4.0.0", 40 | "webpack": "^5.0.0", 41 | "webpack-node-externals": "^3.0.0" 42 | }, 43 | "author": "Erez Rokah", 44 | "license": "MIT" 45 | } 46 | -------------------------------------------------------------------------------- /services/api-service/serverless.yml: -------------------------------------------------------------------------------- 1 | service: api-service 2 | 3 | plugins: 4 | - serverless-webpack 5 | - '@anttiviljami/serverless-stack-output' 6 | - serverless-plugin-tracing 7 | 8 | custom: 9 | defaultStage: dev 10 | currentStage: ${opt:stage, self:custom.defaultStage} 11 | currentRegion: ${file(../common/environment/config.${self:custom.currentStage}.json):region} 12 | 13 | output: 14 | handler: ../../scripts/stackOutput.handler 15 | 16 | webpack: 17 | includeModules: true 18 | packager: yarn 19 | 20 | provider: 21 | name: aws 22 | runtime: nodejs8.10 23 | stage: ${self:custom.currentStage} 24 | region: ${self:custom.currentRegion} 25 | 26 | memorySize: 256 27 | logRetentionInDays: 7 28 | 29 | tracing: true # enable tracing 30 | iamRoleStatements: 31 | - Effect: 'Allow' # xray permissions (required) 32 | Action: 33 | - 'xray:PutTraceSegments' 34 | - 'xray:PutTelemetryRecords' 35 | Resource: 36 | - '*' 37 | 38 | environment: 39 | AUTH0_CLIENT_ID: ${file(../common/environment/config.${self:custom.currentStage}.json):AUTH0_CLIENT_ID} 40 | AUTH0_CLIENT_PUBLIC_KEY: ${file(../common/environment/public_key.${self:custom.currentStage}.pem)} 41 | 42 | functions: 43 | auth: 44 | handler: src/authHandler.auth 45 | cors: true 46 | 47 | publicEndpoint: 48 | handler: src/publicEndpointHandler.publicEndpoint 49 | events: 50 | - http: 51 | path: api/public 52 | method: post 53 | cors: true 54 | 55 | privateEndpoint: 56 | handler: src/privateEndpointHandler.privateEndpoint 57 | events: 58 | - http: 59 | path: api/private 60 | method: post 61 | authorizer: auth 62 | cors: true 63 | 64 | resources: 65 | Resources: 66 | # This response is needed for custom authorizer failures cors support ¯\_(ツ)_/¯ 67 | GatewayResponse: 68 | Type: 'AWS::ApiGateway::GatewayResponse' 69 | Properties: 70 | ResponseParameters: 71 | gatewayresponse.header.Access-Control-Allow-Origin: "'*'" 72 | gatewayresponse.header.Access-Control-Allow-Headers: "'*'" 73 | ResponseType: EXPIRED_TOKEN 74 | RestApiId: 75 | Ref: 'ApiGatewayRestApi' 76 | StatusCode: '401' 77 | AuthFailureGatewayResponse: 78 | Type: 'AWS::ApiGateway::GatewayResponse' 79 | Properties: 80 | ResponseParameters: 81 | gatewayresponse.header.Access-Control-Allow-Origin: "'*'" 82 | gatewayresponse.header.Access-Control-Allow-Headers: "'*'" 83 | ResponseType: UNAUTHORIZED 84 | RestApiId: 85 | Ref: 'ApiGatewayRestApi' 86 | StatusCode: '401' 87 | 88 | package: 89 | individually: true 90 | exclude: 91 | - coverage/** 92 | - .circleci/** 93 | -------------------------------------------------------------------------------- /services/api-service/src/authHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { auth } from './authHandler'; 2 | 3 | jest.mock('../../common/src/auth/authenticator'); 4 | 5 | describe('auth handler', () => { 6 | const { decodeToken } = require('../../common/src/auth/authenticator'); 7 | const response = {}; 8 | const error = new Error('some error'); 9 | 10 | beforeEach(() => { 11 | jest.clearAllMocks(); 12 | }); 13 | 14 | test('should call callback on response', async () => { 15 | decodeToken.mockReturnValue(Promise.resolve({ response, error: null })); 16 | 17 | const event = { 18 | authorizationToken: 'authorizationToken', 19 | methodArn: 'methodArn', 20 | }; 21 | const context: any = null; 22 | const callback = jest.fn(); 23 | 24 | await auth(event, context, callback); 25 | 26 | expect(decodeToken).toHaveBeenCalledTimes(1); 27 | expect(decodeToken).toHaveBeenCalledWith( 28 | event.authorizationToken, 29 | event.methodArn, 30 | ); 31 | expect(callback).toHaveBeenCalledTimes(1); 32 | expect(callback).toHaveBeenCalledWith(null, response); 33 | }); 34 | 35 | test('should call callback on error', async () => { 36 | decodeToken.mockReturnValue(Promise.resolve({ response, error })); 37 | 38 | const event = { 39 | authorizationToken: 'authorizationToken', 40 | methodArn: 'methodArn', 41 | }; 42 | const context: any = null; 43 | const callback = jest.fn(); 44 | 45 | await auth(event, context, callback); 46 | 47 | expect(decodeToken).toHaveBeenCalledTimes(1); 48 | expect(decodeToken).toHaveBeenCalledWith( 49 | event.authorizationToken, 50 | event.methodArn, 51 | ); 52 | expect(callback).toHaveBeenCalledTimes(1); 53 | expect(callback).toHaveBeenCalledWith(error); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /services/api-service/src/authHandler.ts: -------------------------------------------------------------------------------- 1 | import { Callback, Context, CustomAuthorizerEvent, Handler } from 'aws-lambda'; 2 | import { decodeToken } from '../../common/src/auth/authenticator'; 3 | 4 | // Reusable Authorizer function, set on `authorizer` field in serverless.yml 5 | export const auth: Handler = ( 6 | event: CustomAuthorizerEvent, 7 | context: Context, 8 | callback: Callback, 9 | ) => { 10 | console.log(event); 11 | return decodeToken(event.authorizationToken, event.methodArn).then( 12 | ({ response, error }) => { 13 | if (error) { 14 | callback && callback(error); 15 | } else { 16 | callback && callback(null, response); 17 | } 18 | }, 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /services/api-service/src/privateEndpointHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { privateEndpoint } from './privateEndpointHandler'; 2 | 3 | describe('private endpoint handler', () => { 4 | beforeEach(() => { 5 | jest.clearAllMocks(); 6 | }); 7 | 8 | test('should call callback on event', () => { 9 | const event = {}; 10 | const context: any = null; 11 | const callback = jest.fn(); 12 | 13 | privateEndpoint(event, context, callback); 14 | 15 | expect(callback).toHaveBeenCalledTimes(1); 16 | expect(callback).toHaveBeenCalledWith(null, { 17 | body: JSON.stringify({ 18 | message: 19 | 'Hi ⊂◉‿◉つ from Private API. Only logged in users can see this', 20 | }), 21 | headers: { 22 | /* Required for cookies, authorization headers with HTTPS */ 23 | 'Access-Control-Allow-Credentials': true, 24 | /* Required for CORS support to work */ 25 | 'Access-Control-Allow-Origin': '*', 26 | }, 27 | statusCode: 200, 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /services/api-service/src/privateEndpointHandler.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayEvent, Callback, Context, Handler } from 'aws-lambda'; 2 | 3 | // Reusable Authorizer function, set on `authorizer` field in serverless.yml 4 | export const privateEndpoint: Handler = ( 5 | event: APIGatewayEvent, 6 | context: Context, 7 | callback: Callback, 8 | ) => { 9 | callback(null, { 10 | body: JSON.stringify({ 11 | message: 'Hi ⊂◉‿◉つ from Private API. Only logged in users can see this', 12 | }), 13 | headers: { 14 | /* Required for cookies, authorization headers with HTTPS */ 15 | 'Access-Control-Allow-Credentials': true, 16 | /* Required for CORS support to work */ 17 | 'Access-Control-Allow-Origin': '*', 18 | }, 19 | statusCode: 200, 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /services/api-service/src/publicEndpointHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { publicEndpoint } from './publicEndpointHandler'; 2 | 3 | describe('private endpoint handler', () => { 4 | beforeEach(() => { 5 | jest.clearAllMocks(); 6 | }); 7 | 8 | test('should call callback on event', () => { 9 | const event = {}; 10 | const context: any = null; 11 | const callback = jest.fn(); 12 | 13 | publicEndpoint(event, context, callback); 14 | 15 | expect(callback).toHaveBeenCalledTimes(1); 16 | expect(callback).toHaveBeenCalledWith(null, { 17 | body: JSON.stringify({ 18 | message: 'Hi ⊂◉‿◉つ from Public API', 19 | }), 20 | headers: { 21 | /* Required for cookies, authorization headers with HTTPS */ 22 | 'Access-Control-Allow-Credentials': true, 23 | /* Required for CORS support to work */ 24 | 'Access-Control-Allow-Origin': '*', 25 | }, 26 | statusCode: 200, 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /services/api-service/src/publicEndpointHandler.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayEvent, Callback, Context, Handler } from 'aws-lambda'; 2 | 3 | // Reusable Authorizer function, set on `authorizer` field in serverless.yml 4 | export const publicEndpoint: Handler = ( 5 | event: APIGatewayEvent, 6 | context: Context, 7 | callback: Callback, 8 | ) => { 9 | callback(null, { 10 | body: JSON.stringify({ 11 | message: 'Hi ⊂◉‿◉つ from Public API', 12 | }), 13 | headers: { 14 | /* Required for cookies, authorization headers with HTTPS */ 15 | 'Access-Control-Allow-Credentials': true, 16 | /* Required for CORS support to work */ 17 | 'Access-Control-Allow-Origin': '*', 18 | }, 19 | statusCode: 200, 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /services/api-service/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const slsw = require('serverless-webpack'); 3 | const nodeExternals = require('webpack-node-externals'); 4 | 5 | module.exports = { 6 | entry: slsw.lib.entries, 7 | target: 'node', 8 | mode: slsw.lib.webpack.isLocal ? 'development' : 'production', 9 | optimization: { 10 | // We no not want to minimize our code. 11 | minimize: false, 12 | }, 13 | performance: { 14 | // Turn off size warnings for entry points 15 | hints: false, 16 | }, 17 | devtool: 'nosources-source-map', 18 | externals: [nodeExternals()], 19 | resolve: { 20 | extensions: ['.js', '.json', '.ts', '.tsx'], 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.ts(x?)$/, 26 | use: [ 27 | { 28 | loader: 'ts-loader', 29 | }, 30 | ], 31 | }, 32 | { 33 | test: /\.mjs$/, 34 | type: 'javascript/auto', 35 | }, 36 | ], 37 | }, 38 | output: { 39 | libraryTarget: 'commonjs2', 40 | path: path.join(__dirname, '.webpack'), 41 | filename: '[name].js', 42 | sourceMapFilename: '[file].map', 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /services/common/.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Webpack directories 6 | .webpack 7 | 8 | config.*.json 9 | !config.example.json 10 | 11 | public_key.*.pem 12 | !public_key.example.pem 13 | 14 | coverage 15 | 16 | junit.xml 17 | 18 | out.txt -------------------------------------------------------------------------------- /services/common/README.md: -------------------------------------------------------------------------------- 1 | # Shared Services Code 2 | 3 | ## Setup 4 | 5 | ```bash 6 | yarn install 7 | ``` 8 | 9 | ## Create Env Variables From pem files 10 | 11 | ```bash 12 | base64 public_key.example.pem 13 | ``` 14 | -------------------------------------------------------------------------------- /services/common/environment/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "region": "REGION_VALUE", 3 | "AUTH0_CLIENT_ID": "AUTH0_CLIENT_ID_VALUE" 4 | } 5 | -------------------------------------------------------------------------------- /services/common/environment/public_key.example.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | PUBLIC KEY - can be found in https://manage.auth0.com -> applications -> advanced settings -> Certificates 3 | Replace this file with public_key 4 | -----END CERTIFICATE----- 5 | -------------------------------------------------------------------------------- /services/common/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | reporters: ['default', 'jest-junit'], 9 | }; 10 | -------------------------------------------------------------------------------- /services/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "common", 3 | "version": "0.0.1", 4 | "description": "Shared Services Code", 5 | "scripts": { 6 | "lint": "tslint 'src/**/*.ts'", 7 | "test": "jest", 8 | "test:watch": "jest --watch", 9 | "test:ci": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-test-results.xml jest --runInBand --ci", 10 | "coverage": "jest --coverage", 11 | "prettier": "prettier --write src/**/*.ts", 12 | "prettier:ci": "prettier --list-different src/**/*.ts" 13 | }, 14 | "dependencies": { 15 | "jsonwebtoken": "^8.5.1" 16 | }, 17 | "devDependencies": { 18 | "@types/aws-lambda": "^8.10.62", 19 | "@types/jest": "^27.0.0", 20 | "@types/jsonwebtoken": "^8.5.0", 21 | "@types/node": "^16.0.0", 22 | "jest": "27.5.1", 23 | "ts-jest": "27.1.5", 24 | "ts-loader": "^9.0.0", 25 | "tslint": "^6.0.0", 26 | "tslint-config-prettier": "^1.18.0", 27 | "typescript": "^4.0.0" 28 | }, 29 | "author": "Erez Rokah", 30 | "license": "MIT" 31 | } 32 | -------------------------------------------------------------------------------- /services/common/src/auth/authenticator.ts: -------------------------------------------------------------------------------- 1 | import jwt = require('jsonwebtoken'); 2 | 3 | // Set in `environment` of serverless.yml 4 | const AUTH0_CLIENT_ID = process.env.AUTH0_CLIENT_ID; 5 | // istanbul ignore next 6 | const AUTH0_CLIENT_PUBLIC_KEY = process.env.AUTH0_CLIENT_PUBLIC_KEY || ''; 7 | 8 | // Policy helper function 9 | export const generatePolicy = ( 10 | principalId: string, 11 | effect: string, 12 | resource: string, 13 | ) => { 14 | if (effect && resource) { 15 | const policyDocument = { 16 | Statement: [ 17 | { 18 | Action: 'execute-api:Invoke', 19 | Effect: effect, 20 | Resource: resource, 21 | }, 22 | ], 23 | Version: '2012-10-17', 24 | }; 25 | return { principalId, policyDocument }; 26 | } else { 27 | return { principalId }; 28 | } 29 | }; 30 | 31 | export const decodeToken = async ( 32 | authorizationToken: string | undefined, 33 | methodArn: string, 34 | ) => { 35 | const error = 'Unauthorized'; 36 | if (!authorizationToken) { 37 | console.log('missing authorizationToken'); 38 | return { response: null, error }; 39 | } 40 | 41 | const tokenParts = authorizationToken.split(' '); 42 | const tokenValue = tokenParts[1]; 43 | 44 | if (!(tokenParts[0].toLowerCase() === 'bearer' && tokenValue)) { 45 | console.log('invalid authorizationToken value'); 46 | return { response: null, error }; 47 | } 48 | 49 | const options = { 50 | audience: AUTH0_CLIENT_ID, 51 | }; 52 | 53 | const promise = new Promise((resolve, reject) => { 54 | jwt.verify(tokenValue, AUTH0_CLIENT_PUBLIC_KEY, options, (err, decoded) => { 55 | if (err) { 56 | reject(err); 57 | } else { 58 | resolve(decoded); 59 | } 60 | }); 61 | }); 62 | 63 | try { 64 | const decoded = (await promise) as { sub: string }; 65 | console.log('valid from customAuthorizer', decoded); 66 | const response = exports.generatePolicy(decoded.sub, 'Allow', methodArn); 67 | return { response, error: null }; 68 | } catch (err) { 69 | console.log('verifyError', err); 70 | return { response: null, error }; 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /services/db-service/.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless 7 | 8 | # Webpack directories 9 | .webpack 10 | 11 | config.*.json 12 | !config.example.json 13 | 14 | coverage 15 | 16 | junit.xml 17 | 18 | out.txt -------------------------------------------------------------------------------- /services/db-service/README.md: -------------------------------------------------------------------------------- 1 | # Db Service 2 | 3 | ## Setup 4 | 5 | ```bash 6 | yarn install 7 | ``` 8 | -------------------------------------------------------------------------------- /services/db-service/e2e/db.chai.test.ts: -------------------------------------------------------------------------------- 1 | import awsTesting from 'aws-testing-library/lib/chai'; 2 | import { clearAllItems } from 'aws-testing-library/lib/utils/dynamoDb'; 3 | import { invoke } from 'aws-testing-library/lib/utils/lambda'; 4 | import chai = require('chai'); 5 | 6 | chai.use(awsTesting); 7 | 8 | const { expect } = chai; 9 | 10 | describe('db service e2e tests', () => { 11 | const region = 'us-east-1'; 12 | const table = 'db-service-dev'; 13 | beforeEach(async () => { 14 | await clearAllItems(region, table); 15 | }); 16 | 17 | afterEach(async () => { 18 | await clearAllItems(region, table); 19 | }); 20 | 21 | test('should create db entry on lambda invoke', async () => { 22 | const result = await invoke(region, 'db-service-dev-create', { 23 | body: JSON.stringify({ text: 'from e2e test' }), 24 | }); 25 | const lambdaItem = JSON.parse(result.body); 26 | 27 | await expect({ region, table, timeout: 0 }).to.have.item( 28 | { id: lambdaItem.id }, 29 | lambdaItem, 30 | ); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /services/db-service/e2e/db.test.ts: -------------------------------------------------------------------------------- 1 | import { clearAllItems } from 'aws-testing-library/lib/utils/dynamoDb'; 2 | import { invoke } from 'aws-testing-library/lib/utils/lambda'; 3 | 4 | describe('db service e2e tests', () => { 5 | const region = 'us-east-1'; 6 | const table = 'db-service-dev'; 7 | beforeEach(async () => { 8 | await clearAllItems(region, table); 9 | }); 10 | 11 | afterEach(async () => { 12 | await clearAllItems(region, table); 13 | }); 14 | 15 | test('should create db entry on lambda invoke', async () => { 16 | const result = await invoke(region, 'db-service-dev-create', { 17 | body: JSON.stringify({ text: 'from e2e test' }), 18 | }); 19 | const lambdaItem = JSON.parse(result.body); 20 | 21 | expect.assertions(1); 22 | await expect({ region, table, timeout: 0 }).toHaveItem( 23 | { id: lambdaItem.id }, 24 | lambdaItem, 25 | ); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /services/db-service/e2e/setup.ts: -------------------------------------------------------------------------------- 1 | import { deploy } from 'aws-testing-library/lib/utils/serverless'; 2 | 3 | const deployDev = async () => { 4 | await deploy('dev'); 5 | }; 6 | 7 | export default deployDev; 8 | -------------------------------------------------------------------------------- /services/db-service/e2e/setupFrameworks.ts: -------------------------------------------------------------------------------- 1 | import 'aws-testing-library/lib/jest'; 2 | -------------------------------------------------------------------------------- /services/db-service/e2e/setup_hook.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register'); 2 | module.exports = require('./setup').default; 3 | -------------------------------------------------------------------------------- /services/db-service/jest.config.e2e.js: -------------------------------------------------------------------------------- 1 | const config = require('./jest.config'); 2 | 3 | module.exports = { 4 | ...config, 5 | roots: ['/e2e'], 6 | globalSetup: '/e2e/setup_hook.js', 7 | setupTestFrameworkScriptFile: '/e2e/setupFrameworks.ts', 8 | }; 9 | -------------------------------------------------------------------------------- /services/db-service/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | reporters: ['default', 'jest-junit'], 9 | testEnvironment: 'node', 10 | }; 11 | -------------------------------------------------------------------------------- /services/db-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "db-service", 3 | "version": "0.0.1", 4 | "description": "File Service", 5 | "scripts": { 6 | "lint": "tslint 'src/**/*.ts'", 7 | "test": "jest", 8 | "test:watch": "jest --watch", 9 | "test:ci": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-test-results.xml jest --runInBand --ci", 10 | "test:e2e": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-e2e-test-results.xml jest --config=jest.config.e2e.js --runInBand --ci", 11 | "coverage": "jest --coverage", 12 | "deploy": "serverless deploy", 13 | "remove": "serverless remove", 14 | "prettier": "prettier --write src/**/*.ts", 15 | "prettier:ci": "prettier --list-different src/**/*.ts" 16 | }, 17 | "dependencies": { 18 | "aws-sdk": "^2.449.0", 19 | "uuid": "^8.0.0" 20 | }, 21 | "devDependencies": { 22 | "@anttiviljami/serverless-stack-output": "^0.3.1", 23 | "@types/aws-lambda": "^8.10.62", 24 | "@types/chai": "^4.2.12", 25 | "@types/jest": "^27.0.0", 26 | "@types/node": "^16.0.0", 27 | "@types/uuid": "^8.0.0", 28 | "aws-testing-library": "^2.0.0", 29 | "chai": "^4.2.0", 30 | "dotenv": "^16.0.0", 31 | "jest": "27.5.1", 32 | "mockdate": "^3.0.0", 33 | "serverless-plugin-tracing": "^2.0.0", 34 | "serverless-webpack": "^5.2.0", 35 | "ts-jest": "27.1.5", 36 | "ts-loader": "^9.0.0", 37 | "tslint": "^6.0.0", 38 | "tslint-config-prettier": "^1.18.0", 39 | "typescript": "^4.0.0", 40 | "webpack": "^5.0.0", 41 | "webpack-node-externals": "^3.0.0" 42 | }, 43 | "author": "Erez Rokah", 44 | "license": "MIT" 45 | } 46 | -------------------------------------------------------------------------------- /services/db-service/serverless.yml: -------------------------------------------------------------------------------- 1 | service: db-service 2 | 3 | plugins: 4 | - serverless-webpack 5 | - '@anttiviljami/serverless-stack-output' 6 | - serverless-plugin-tracing 7 | 8 | custom: 9 | defaultStage: dev 10 | currentStage: ${opt:stage, self:custom.defaultStage} 11 | currentRegion: ${file(../common/environment/config.${self:custom.currentStage}.json):region} 12 | 13 | output: 14 | handler: ../../scripts/stackOutput.handler 15 | 16 | webpack: 17 | includeModules: true 18 | packager: yarn 19 | 20 | provider: 21 | name: aws 22 | runtime: nodejs8.10 23 | stage: ${self:custom.currentStage} 24 | region: ${self:custom.currentRegion} 25 | 26 | memorySize: 256 27 | logRetentionInDays: 7 28 | 29 | tracing: true # enable tracing 30 | 31 | environment: 32 | DYNAMODB_TABLE: ${self:service}-${opt:stage, self:provider.stage} 33 | 34 | iamRoleStatements: 35 | - Effect: 'Allow' # xray permissions (required) 36 | Action: 37 | - 'xray:PutTraceSegments' 38 | - 'xray:PutTelemetryRecords' 39 | Resource: 40 | - '*' 41 | 42 | - Effect: Allow 43 | Action: 44 | - dynamodb:Query 45 | - dynamodb:Scan 46 | - dynamodb:GetItem 47 | - dynamodb:PutItem 48 | - dynamodb:UpdateItem 49 | - dynamodb:DeleteItem 50 | Resource: 'arn:aws:dynamodb:${opt:region, self:provider.region}:*:table/${self:provider.environment.DYNAMODB_TABLE}' 51 | 52 | functions: 53 | create: 54 | handler: src/create.create 55 | events: 56 | - http: 57 | path: todos 58 | method: post 59 | cors: true 60 | 61 | resources: 62 | Resources: 63 | TodosDynamoDbTable: 64 | Type: 'AWS::DynamoDB::Table' 65 | DeletionPolicy: Retain 66 | Properties: 67 | AttributeDefinitions: 68 | - AttributeName: id 69 | AttributeType: S 70 | KeySchema: 71 | - AttributeName: id 72 | KeyType: HASH 73 | ProvisionedThroughput: 74 | ReadCapacityUnits: 1 75 | WriteCapacityUnits: 1 76 | TableName: ${self:provider.environment.DYNAMODB_TABLE} 77 | -------------------------------------------------------------------------------- /services/db-service/src/create.test.ts: -------------------------------------------------------------------------------- 1 | import { create } from './create'; 2 | 3 | jest.mock('./db'); 4 | 5 | describe('create handler', () => { 6 | const { create: dbCreate } = require('./db'); 7 | const response = {}; 8 | const error = new Error('some error'); 9 | 10 | const payload = { 11 | text: 'text', 12 | }; 13 | const event = { body: JSON.stringify(payload) }; 14 | const context: any = null; 15 | 16 | beforeEach(() => { 17 | jest.clearAllMocks(); 18 | }); 19 | 20 | test('should call callback on response', async () => { 21 | dbCreate.mockReturnValue(Promise.resolve({ response, error: null })); 22 | 23 | const callback = jest.fn(); 24 | 25 | await create(event, context, callback); 26 | 27 | expect(dbCreate).toHaveBeenCalledTimes(1); 28 | expect(dbCreate).toHaveBeenCalledWith(JSON.stringify(payload)); 29 | expect(callback).toHaveBeenCalledTimes(1); 30 | expect(callback).toHaveBeenCalledWith(null, response); 31 | }); 32 | 33 | test('should call callback on error', async () => { 34 | dbCreate.mockReturnValue(Promise.resolve({ response, error })); 35 | 36 | const callback = jest.fn(); 37 | 38 | await create(event, context, callback); 39 | 40 | expect(dbCreate).toHaveBeenCalledTimes(1); 41 | expect(dbCreate).toHaveBeenCalledWith(JSON.stringify(payload)); 42 | expect(callback).toHaveBeenCalledTimes(1); 43 | expect(callback).toHaveBeenCalledWith(error); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /services/db-service/src/create.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayEvent, Callback, Context, Handler } from 'aws-lambda'; 2 | import { create as dbCreate } from './db'; 3 | 4 | export const create: Handler = ( 5 | event: APIGatewayEvent, 6 | context: Context, 7 | callback?: Callback, 8 | ) => { 9 | return dbCreate(event.body).then(({ response, error }) => { 10 | if (error) { 11 | callback && callback(error); 12 | } else { 13 | callback && callback(null, response); 14 | } 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /services/db-service/src/db.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock('aws-sdk', () => { 2 | const promise = jest.fn(); 3 | const put = jest.fn(() => ({ promise })); 4 | const DocumentClient = jest.fn(() => ({ put })); 5 | const DynamoDB = { DocumentClient }; 6 | return { DynamoDB }; 7 | }); 8 | jest.spyOn(console, 'log'); 9 | jest.mock('uuid/v1'); 10 | 11 | process.env.DYNAMODB_TABLE = 'TableName'; 12 | 13 | describe('db', () => { 14 | const headers = { 15 | 'Access-Control-Allow-Credentials': true, 16 | 'Access-Control-Allow-Origin': '*', 17 | }; 18 | 19 | const MockDate = require('mockdate'); 20 | 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | 24 | const date = '1/1/2000'; 25 | MockDate.set(date); 26 | }); 27 | 28 | afterEach(() => { 29 | MockDate.reset(); 30 | }); 31 | 32 | const badDataResponse = (input: any) => ({ 33 | body: JSON.stringify({ 34 | input, 35 | message: 'Bad input data or missing text', 36 | }), 37 | headers, 38 | statusCode: 422, 39 | }); 40 | 41 | const AWS = require('aws-sdk'); 42 | const dynamoDB = AWS.DynamoDB.DocumentClient; 43 | 44 | test('should return response on empty body', async () => { 45 | const { create } = require('./db'); 46 | 47 | const body = ''; 48 | 49 | const { response, error } = await create(body); 50 | 51 | expect(dynamoDB).toHaveBeenCalledTimes(1); 52 | expect(response).toEqual(badDataResponse(body)); 53 | expect(error).toBeNull(); 54 | }); 55 | 56 | test('should return response on malformed body', async () => { 57 | const { create } = require('./db'); 58 | 59 | const body = 'invalid-json'; 60 | 61 | const { response, error } = await create(body); 62 | 63 | expect(dynamoDB).toHaveBeenCalledTimes(1); 64 | expect(response).toEqual(badDataResponse(body)); 65 | expect(error).toBeNull(); 66 | expect(console.log).toHaveBeenCalledTimes(1); 67 | expect(console.log).toHaveBeenCalledWith( 68 | 'Invalid body', 69 | expect.any(SyntaxError), 70 | ); 71 | }); 72 | 73 | test('should return response on empty text', async () => { 74 | const { create } = require('./db'); 75 | 76 | const body = JSON.stringify({ text: '' }); 77 | 78 | const { response, error } = await create(body); 79 | 80 | expect(dynamoDB).toHaveBeenCalledTimes(1); 81 | expect(response).toEqual(badDataResponse(body)); 82 | expect(error).toBeNull(); 83 | }); 84 | 85 | test('should return response on valid text', async () => { 86 | const { create } = require('./db'); 87 | 88 | const uuid = require('uuid/v1'); 89 | const id = 'someUuid'; 90 | uuid.mockReturnValue(id); 91 | 92 | const text = 'textToWrite'; 93 | const body = JSON.stringify({ text }); 94 | 95 | const { response, error } = await create(body); 96 | 97 | expect(uuid).toHaveBeenCalledTimes(1); 98 | 99 | const params = { 100 | Item: { 101 | checked: false, 102 | createdAt: new Date().getTime(), 103 | id, 104 | text, 105 | updatedAt: new Date().getTime(), 106 | }, 107 | TableName: process.env.DYNAMODB_TABLE, 108 | }; 109 | 110 | expect(dynamoDB().put).toHaveBeenCalledTimes(1); 111 | expect(dynamoDB().put).toHaveBeenCalledWith(params); 112 | expect(dynamoDB().put().promise).toHaveBeenCalledTimes(1); 113 | 114 | expect(response).toEqual({ 115 | body: JSON.stringify(params.Item), 116 | headers, 117 | statusCode: 200, 118 | }); 119 | expect(error).toBeNull(); 120 | }); 121 | 122 | test('should return response on promise rejects', async () => { 123 | const { create } = require('./db'); 124 | 125 | const text = 'textToWrite'; 126 | const body = JSON.stringify({ text }); 127 | 128 | const expectedError = new Error(`Failed to save to db`); 129 | dynamoDB() 130 | .put() 131 | .promise.mockReturnValue(Promise.reject(expectedError)); 132 | 133 | const { response, error } = await create(body); 134 | 135 | expect(response).toEqual({ 136 | body: JSON.stringify({ 137 | input: body, 138 | message: "Couldn't create the todo item.", 139 | }), 140 | headers, 141 | statusCode: 500, 142 | }); 143 | 144 | expect(error).toEqual(expectedError); 145 | expect(console.log).toHaveBeenCalledTimes(1); 146 | expect(console.log).toHaveBeenCalledWith(error); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /services/db-service/src/db.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDB } from 'aws-sdk'; 2 | import { v1 as uuid } from 'uuid'; 3 | 4 | const TableName = process.env.DYNAMODB_TABLE || ''; 5 | 6 | const headers = { 7 | /* Required for cookies, authorization headers with HTTPS */ 8 | 'Access-Control-Allow-Credentials': true, 9 | /* Required for CORS support to work */ 10 | 'Access-Control-Allow-Origin': '*', 11 | }; 12 | 13 | export const create = async (body: string | null) => { 14 | const dynamoDb = new DynamoDB.DocumentClient(); 15 | 16 | let text = ''; 17 | if (body) { 18 | try { 19 | const parsed = JSON.parse(body); 20 | text = parsed.text || ''; 21 | } catch (e) { 22 | console.log('Invalid body', e); 23 | } 24 | } 25 | 26 | if (text !== '' && typeof text === 'string') { 27 | try { 28 | const createdAt = new Date().getTime(); 29 | const params = { 30 | Item: { 31 | checked: false, 32 | createdAt, 33 | id: uuid(), 34 | text, 35 | updatedAt: createdAt, 36 | }, 37 | TableName, 38 | }; 39 | 40 | // write the todo to the database 41 | await dynamoDb.put(params).promise(); 42 | 43 | // create a response 44 | const response = { 45 | body: JSON.stringify(params.Item), 46 | headers, 47 | statusCode: 200, 48 | }; 49 | return { response, error: null }; 50 | } catch (error) { 51 | console.log(error); 52 | const response = { 53 | body: JSON.stringify({ 54 | input: body, 55 | message: "Couldn't create the todo item.", 56 | }), 57 | headers, 58 | statusCode: 500, 59 | }; 60 | return { response, error }; 61 | } 62 | } else { 63 | const response = { 64 | body: JSON.stringify({ 65 | input: body, 66 | message: 'Bad input data or missing text', 67 | }), 68 | headers, 69 | statusCode: 422, 70 | }; 71 | return { response, error: null }; 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /services/db-service/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const slsw = require('serverless-webpack'); 3 | const nodeExternals = require('webpack-node-externals'); 4 | 5 | module.exports = { 6 | entry: slsw.lib.entries, 7 | target: 'node', 8 | mode: slsw.lib.webpack.isLocal ? 'development' : 'production', 9 | optimization: { 10 | // We no not want to minimize our code. 11 | minimize: false, 12 | }, 13 | performance: { 14 | // Turn off size warnings for entry points 15 | hints: false, 16 | }, 17 | devtool: 'nosources-source-map', 18 | externals: [nodeExternals()], 19 | resolve: { 20 | extensions: ['.js', '.json', '.ts', '.tsx'], 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.ts(x?)$/, 26 | use: [ 27 | { 28 | loader: 'ts-loader', 29 | }, 30 | ], 31 | }, 32 | { 33 | test: /\.mjs$/, 34 | type: 'javascript/auto', 35 | }, 36 | ], 37 | }, 38 | output: { 39 | libraryTarget: 'commonjs2', 40 | path: path.join(__dirname, '.webpack'), 41 | filename: '[name].js', 42 | sourceMapFilename: '[file].map', 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /services/email-service/.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless 7 | 8 | # Webpack directories 9 | .webpack 10 | 11 | config.*.json 12 | !config.example.json 13 | 14 | coverage 15 | 16 | junit.xml -------------------------------------------------------------------------------- /services/email-service/README.md: -------------------------------------------------------------------------------- 1 | # Email Service 2 | 3 | ## Setup 4 | 5 | ```bash 6 | yarn install 7 | ``` 8 | 9 | ## Invoke Locally 10 | 11 | ```bash 12 | sls invoke local --stage dev --function send -p send-email-data.json 13 | ``` 14 | -------------------------------------------------------------------------------- /services/email-service/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "SENDGRID_API_KEY": "SENDGRID_API_KEY_VALUE" 3 | } 4 | -------------------------------------------------------------------------------- /services/email-service/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | reporters: ['default', 'jest-junit'], 9 | testEnvironment: 'node', 10 | }; 11 | -------------------------------------------------------------------------------- /services/email-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "email-service", 3 | "version": "0.0.1", 4 | "description": "Serverless Email Service", 5 | "main": "handler.ts", 6 | "scripts": { 7 | "lint": "tslint 'src/**/*.ts'", 8 | "test": "jest", 9 | "test:watch": "jest --watch", 10 | "test:ci": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-service-test-results.xml jest --runInBand --ci", 11 | "coverage": "jest --coverage", 12 | "deploy": "serverless deploy", 13 | "remove": "serverless remove", 14 | "prettier": "prettier --write src/**/*.ts", 15 | "prettier:ci": "prettier --list-different src/**/*.ts" 16 | }, 17 | "dependencies": { 18 | "@sendgrid/mail": "^7.0.0", 19 | "aws-xray-sdk": "^3.0.0", 20 | "source-map-support": "^0.5.12" 21 | }, 22 | "devDependencies": { 23 | "@types/aws-lambda": "^8.10.62", 24 | "@types/jest": "^27.0.0", 25 | "@types/node": "^16.0.0", 26 | "dotenv": "^16.0.0", 27 | "fs-extra": "^10.0.0", 28 | "jest": "27.5.1", 29 | "serverless-plugin-tracing": "^2.0.0", 30 | "@anttiviljami/serverless-stack-output": "^0.3.1", 31 | "serverless-webpack": "^5.2.0", 32 | "ts-jest": "27.1.5", 33 | "ts-loader": "^9.0.0", 34 | "tslint": "^6.0.0", 35 | "tslint-config-prettier": "^1.18.0", 36 | "typescript": "^4.0.0", 37 | "webpack": "^5.0.0", 38 | "webpack-node-externals": "^3.0.0" 39 | }, 40 | "author": "Erez Rokah", 41 | "license": "MIT" 42 | } 43 | -------------------------------------------------------------------------------- /services/email-service/send-email-data.json: -------------------------------------------------------------------------------- 1 | { 2 | "resource": "/email", 3 | "path": "/email", 4 | "httpMethod": "POST", 5 | "headers": {}, 6 | "queryStringParameters": null, 7 | "pathParameters": null, 8 | "stageVariables": null, 9 | "requestContext": { 10 | "path": "/dev/email", 11 | "stage": "dev", 12 | "requestId": "123", 13 | "resourcePath": "/email", 14 | "httpMethod": "POST" 15 | }, 16 | "body": "{\"to_address\":\"erezrokah@gmail.com\"}", 17 | "isBase64Encoded": false 18 | } 19 | -------------------------------------------------------------------------------- /services/email-service/serverless.yml: -------------------------------------------------------------------------------- 1 | service: email-service 2 | 3 | plugins: 4 | - serverless-webpack 5 | - '@anttiviljami/serverless-stack-output' 6 | - serverless-plugin-tracing 7 | 8 | custom: 9 | defaultStage: dev 10 | currentStage: ${opt:stage, self:custom.defaultStage} 11 | currentRegion: ${file(../common/environment/config.${self:custom.currentStage}.json):region} 12 | 13 | output: 14 | handler: ../../scripts/stackOutput.handler 15 | 16 | webpack: 17 | includeModules: true 18 | packager: yarn 19 | 20 | provider: 21 | name: aws 22 | runtime: nodejs8.10 23 | stage: ${self:custom.currentStage} 24 | region: ${self:custom.currentRegion} 25 | 26 | memorySize: 256 27 | logRetentionInDays: 7 28 | 29 | tracing: true # enable tracing 30 | iamRoleStatements: 31 | - Effect: 'Allow' # xray permissions (required) 32 | Action: 33 | - 'xray:PutTraceSegments' 34 | - 'xray:PutTelemetryRecords' 35 | Resource: 36 | - '*' 37 | 38 | environment: 39 | AUTH0_CLIENT_ID: ${file(../common/environment/config.${self:custom.currentStage}.json):AUTH0_CLIENT_ID} 40 | AUTH0_CLIENT_PUBLIC_KEY: ${file(../common/environment/public_key.${self:custom.currentStage}.pem)} 41 | 42 | SENDGRID_API_KEY: ${file(./config.${self:custom.currentStage}.json):SENDGRID_API_KEY} 43 | 44 | functions: 45 | auth: 46 | handler: src/authHandler.auth 47 | cors: true 48 | 49 | send: 50 | handler: src/handler.sendEmail 51 | events: 52 | - http: 53 | path: email 54 | method: post 55 | authorizer: auth 56 | cors: true 57 | 58 | resources: 59 | Resources: 60 | # This response is needed for custom authorizer failures cors support ¯\_(ツ)_/¯ 61 | GatewayResponse: 62 | Type: 'AWS::ApiGateway::GatewayResponse' 63 | Properties: 64 | ResponseParameters: 65 | gatewayresponse.header.Access-Control-Allow-Origin: "'*'" 66 | gatewayresponse.header.Access-Control-Allow-Headers: "'*'" 67 | ResponseType: EXPIRED_TOKEN 68 | RestApiId: 69 | Ref: 'ApiGatewayRestApi' 70 | StatusCode: '401' 71 | AuthFailureGatewayResponse: 72 | Type: 'AWS::ApiGateway::GatewayResponse' 73 | Properties: 74 | ResponseParameters: 75 | gatewayresponse.header.Access-Control-Allow-Origin: "'*'" 76 | gatewayresponse.header.Access-Control-Allow-Headers: "'*'" 77 | ResponseType: UNAUTHORIZED 78 | RestApiId: 79 | Ref: 'ApiGatewayRestApi' 80 | StatusCode: '401' 81 | 82 | package: 83 | individually: true 84 | exclude: 85 | - coverage/** 86 | - .circleci/** 87 | -------------------------------------------------------------------------------- /services/email-service/src/authHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { auth } from './authHandler'; 2 | 3 | jest.mock('../../common/src/auth/authenticator'); 4 | 5 | describe('auth handler', () => { 6 | const { decodeToken } = require('../../common/src/auth/authenticator'); 7 | const response = {}; 8 | const error = new Error('some error'); 9 | 10 | beforeEach(() => { 11 | jest.clearAllMocks(); 12 | }); 13 | 14 | test('should call callback on response', async () => { 15 | decodeToken.mockReturnValue(Promise.resolve({ response, error: null })); 16 | 17 | const event = { 18 | authorizationToken: 'authorizationToken', 19 | methodArn: 'methodArn', 20 | }; 21 | const context: any = null; 22 | const callback = jest.fn(); 23 | 24 | await auth(event, context, callback); 25 | 26 | expect(decodeToken).toHaveBeenCalledTimes(1); 27 | expect(decodeToken).toHaveBeenCalledWith( 28 | event.authorizationToken, 29 | event.methodArn, 30 | ); 31 | expect(callback).toHaveBeenCalledTimes(1); 32 | expect(callback).toHaveBeenCalledWith(null, response); 33 | }); 34 | 35 | test('should call callback on error', async () => { 36 | decodeToken.mockReturnValue(Promise.resolve({ response, error })); 37 | 38 | const event = { 39 | authorizationToken: 'authorizationToken', 40 | methodArn: 'methodArn', 41 | }; 42 | const context: any = null; 43 | const callback = jest.fn(); 44 | 45 | await auth(event, context, callback); 46 | 47 | expect(decodeToken).toHaveBeenCalledTimes(1); 48 | expect(decodeToken).toHaveBeenCalledWith( 49 | event.authorizationToken, 50 | event.methodArn, 51 | ); 52 | expect(callback).toHaveBeenCalledTimes(1); 53 | expect(callback).toHaveBeenCalledWith(error); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /services/email-service/src/authHandler.ts: -------------------------------------------------------------------------------- 1 | import { Callback, Context, CustomAuthorizerEvent, Handler } from 'aws-lambda'; 2 | import { decodeToken } from '../../common/src/auth/authenticator'; 3 | 4 | // Reusable Authorizer function, set on `authorizer` field in serverless.yml 5 | export const auth: Handler = ( 6 | event: CustomAuthorizerEvent, 7 | context: Context, 8 | callback: Callback, 9 | ) => { 10 | console.log(event); 11 | return decodeToken(event.authorizationToken, event.methodArn).then( 12 | ({ response, error }) => { 13 | if (error) { 14 | callback && callback(error); 15 | } else { 16 | callback && callback(null, response); 17 | } 18 | }, 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /services/email-service/src/handler.test.ts: -------------------------------------------------------------------------------- 1 | import { sendEmail } from './handler'; 2 | 3 | jest.mock('./mailSender'); 4 | 5 | describe('handler', () => { 6 | const { sendMail } = require('./mailSender'); 7 | const response = {}; 8 | const error = new Error('some error'); 9 | 10 | const payload = { to_address: 'to@email.com' }; 11 | const event = { body: JSON.stringify(payload) }; 12 | const context: any = null; 13 | 14 | beforeEach(() => { 15 | jest.clearAllMocks(); 16 | }); 17 | 18 | test('should call callback on response', async () => { 19 | sendMail.mockReturnValue(Promise.resolve({ response, error: null })); 20 | 21 | const callback = jest.fn(); 22 | 23 | await sendEmail(event, context, callback); 24 | 25 | expect(sendMail).toHaveBeenCalledTimes(1); 26 | expect(sendMail).toHaveBeenCalledWith(JSON.stringify(payload)); 27 | expect(callback).toHaveBeenCalledTimes(1); 28 | expect(callback).toHaveBeenCalledWith(null, response); 29 | }); 30 | 31 | test('should call callback on error', async () => { 32 | sendMail.mockReturnValue(Promise.resolve({ response, error })); 33 | 34 | const callback = jest.fn(); 35 | 36 | await sendEmail(event, context, callback); 37 | 38 | expect(sendMail).toHaveBeenCalledTimes(1); 39 | expect(sendMail).toHaveBeenCalledWith(JSON.stringify(payload)); 40 | expect(callback).toHaveBeenCalledTimes(1); 41 | expect(callback).toHaveBeenCalledWith(error); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /services/email-service/src/handler.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:no-var-requires 2 | const AWSXRay = require('aws-xray-sdk'); 3 | // tslint:disable-next-line:no-var-requires 4 | AWSXRay.captureHTTPsGlobal(require('http')); 5 | 6 | import { APIGatewayEvent, Callback, Context, Handler } from 'aws-lambda'; 7 | import { sendMail } from './mailSender'; 8 | 9 | export const sendEmail: Handler = ( 10 | event: APIGatewayEvent, 11 | context: Context, 12 | callback?: Callback, 13 | ) => { 14 | return sendMail(event.body).then(({ response, error }) => { 15 | if (error) { 16 | callback && callback(error); 17 | } else { 18 | callback && callback(null, response); 19 | } 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /services/email-service/src/mailSender.ts: -------------------------------------------------------------------------------- 1 | import sgMail = require('@sendgrid/mail'); 2 | 3 | sgMail.setApiKey(process.env.SENDGRID_API_KEY || ''); 4 | 5 | export const from = ``; 6 | export const subject = 'Serverless Email Demo'; 7 | export const text = 'Sample email sent from Serverless Email Demo.'; 8 | export const html = ` 9 | 10 | Serverless Email Demo 11 | 12 |
13 |

Serverless Email Demo

14 | Sample email sent from Serverless Email Demo. 15 |
16 | 17 | 18 | `; 19 | 20 | export const sendMail = async (body: string | null) => { 21 | let to = ''; 22 | if (body) { 23 | try { 24 | to = JSON.parse(body).to_address || ''; 25 | } catch (e) { 26 | console.log('Invalid body', e); 27 | } 28 | } 29 | 30 | if (to !== '') { 31 | const msg = { 32 | from, 33 | html, 34 | subject, 35 | text, 36 | to, 37 | }; 38 | 39 | try { 40 | const [, input] = await sgMail.send(msg); 41 | const response = { 42 | body: JSON.stringify({ 43 | input, 44 | message: 'Request to send email is successful.', 45 | }), 46 | headers: { 47 | /* Required for cookies, authorization headers with HTTPS */ 48 | 'Access-Control-Allow-Credentials': true, 49 | /* Required for CORS support to work */ 50 | 'Access-Control-Allow-Origin': '*', 51 | }, 52 | statusCode: 202, 53 | }; 54 | console.log(response); 55 | return { response, error: null }; 56 | } catch (error) { 57 | console.log(error); 58 | const response = { 59 | body: JSON.stringify({ 60 | input: body, 61 | message: 'Unknown Error', 62 | }), 63 | headers: { 64 | /* Required for cookies, authorization headers with HTTPS */ 65 | 'Access-Control-Allow-Credentials': true, 66 | /* Required for CORS support to work */ 67 | 'Access-Control-Allow-Origin': '*', 68 | }, 69 | statusCode: 500, 70 | }; 71 | return { response, error }; 72 | } 73 | } else { 74 | const response = { 75 | body: JSON.stringify({ 76 | input: body, 77 | message: 'Bad input data or missing email address.', 78 | }), 79 | headers: { 80 | /* Required for cookies, authorization headers with HTTPS */ 81 | 'Access-Control-Allow-Credentials': true, 82 | /* Required for CORS support to work */ 83 | 'Access-Control-Allow-Origin': '*', 84 | }, 85 | statusCode: 422, 86 | }; 87 | return { response, error: null }; 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /services/email-service/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const slsw = require('serverless-webpack'); 3 | const nodeExternals = require('webpack-node-externals'); 4 | 5 | module.exports = { 6 | entry: slsw.lib.entries, 7 | target: 'node', 8 | mode: slsw.lib.webpack.isLocal ? 'development' : 'production', 9 | optimization: { 10 | // We no not want to minimize our code. 11 | minimize: false, 12 | }, 13 | performance: { 14 | // Turn off size warnings for entry points 15 | hints: false, 16 | }, 17 | devtool: 'nosources-source-map', 18 | externals: [nodeExternals()], 19 | resolve: { 20 | extensions: ['.js', '.json', '.ts', '.tsx'], 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.ts(x?)$/, 26 | use: [ 27 | { 28 | loader: 'ts-loader', 29 | }, 30 | ], 31 | }, 32 | ], 33 | }, 34 | output: { 35 | libraryTarget: 'commonjs2', 36 | path: path.join(__dirname, '.webpack'), 37 | filename: '[name].js', 38 | sourceMapFilename: '[file].map', 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /services/file-service/.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless 7 | 8 | # Webpack directories 9 | .webpack 10 | 11 | config.*.json 12 | !config.example.json 13 | 14 | coverage 15 | 16 | junit.xml 17 | 18 | out.txt -------------------------------------------------------------------------------- /services/file-service/README.md: -------------------------------------------------------------------------------- 1 | # File Service 2 | 3 | ## Setup 4 | 5 | ```bash 6 | yarn install 7 | ``` 8 | -------------------------------------------------------------------------------- /services/file-service/e2e/handler.chai.test.ts: -------------------------------------------------------------------------------- 1 | import awsTesting from 'aws-testing-library/lib/chai'; 2 | import { invoke } from 'aws-testing-library/lib/utils/lambda'; 3 | import { clearAllObjects } from 'aws-testing-library/lib/utils/s3'; 4 | import chai = require('chai'); 5 | import fetch from 'node-fetch'; 6 | 7 | chai.use(awsTesting); 8 | 9 | const { expect } = chai; 10 | 11 | describe('file service e2e tests', () => { 12 | const region = 'us-east-1'; 13 | const bucket = 'file-service-s3-bucket-dev'; 14 | 15 | beforeEach(async () => { 16 | await clearAllObjects(region, bucket); 17 | }); 18 | 19 | afterEach(async () => { 20 | await clearAllObjects(region, bucket); 21 | }); 22 | 23 | test('should create object in s3 on lambda invoke', async () => { 24 | const body = { 25 | file_url: 26 | 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/240px-Google_2015_logo.svg.png', 27 | key: '240px-Google_2015_logo.svg.png', 28 | }; 29 | 30 | const result = await invoke(region, 'file-service-dev-save', { 31 | body: JSON.stringify(body), 32 | }); 33 | 34 | const parsedResult = JSON.parse(result.body); 35 | expect(parsedResult.message).to.be.equal('File saved'); 36 | 37 | const expectedBuffer = await (await fetch(body.file_url)).buffer(); 38 | 39 | await expect({ region, bucket, timeout: 0 }).to.have.object( 40 | body.key, 41 | expectedBuffer, 42 | ); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /services/file-service/e2e/handler.test.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from 'aws-testing-library/lib/utils/lambda'; 2 | import { clearAllObjects } from 'aws-testing-library/lib/utils/s3'; 3 | import fetch from 'node-fetch'; 4 | 5 | describe('file service e2e tests', () => { 6 | const region = 'us-east-1'; 7 | const bucket = 'file-service-s3-bucket-dev'; 8 | 9 | beforeEach(async () => { 10 | await clearAllObjects(region, bucket); 11 | }); 12 | 13 | afterEach(async () => { 14 | await clearAllObjects(region, bucket); 15 | }); 16 | 17 | test('should create object in s3 on lambda invoke', async () => { 18 | const body = { 19 | file_url: 20 | 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/240px-Google_2015_logo.svg.png', 21 | key: '240px-Google_2015_logo.svg.png', 22 | }; 23 | 24 | const result = await invoke(region, 'file-service-dev-save', { 25 | body: JSON.stringify(body), 26 | }); 27 | 28 | const parsedResult = JSON.parse(result.body); 29 | expect(parsedResult.message).toEqual('File saved'); 30 | 31 | const expectedBuffer = await (await fetch(body.file_url)).buffer(); 32 | 33 | expect.assertions(2); 34 | await expect({ region, bucket, timeout: 0 }).toHaveObject( 35 | body.key, 36 | expectedBuffer, 37 | ); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /services/file-service/e2e/setup.ts: -------------------------------------------------------------------------------- 1 | import { deploy } from 'aws-testing-library/lib/utils/serverless'; 2 | 3 | const deployDev = async () => { 4 | await deploy('dev'); 5 | }; 6 | 7 | export default deployDev; 8 | -------------------------------------------------------------------------------- /services/file-service/e2e/setupFrameworks.ts: -------------------------------------------------------------------------------- 1 | import 'aws-testing-library/lib/jest'; 2 | -------------------------------------------------------------------------------- /services/file-service/e2e/setup_hook.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register'); 2 | module.exports = require('./setup').default; 3 | -------------------------------------------------------------------------------- /services/file-service/jest.config.e2e.js: -------------------------------------------------------------------------------- 1 | const config = require('./jest.config'); 2 | 3 | module.exports = { 4 | ...config, 5 | roots: ['/e2e'], 6 | globalSetup: '/e2e/setup_hook.js', 7 | setupTestFrameworkScriptFile: '/e2e/setupFrameworks.ts', 8 | }; 9 | -------------------------------------------------------------------------------- /services/file-service/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | reporters: ['default', 'jest-junit'], 9 | testEnvironment: 'node', 10 | }; 11 | -------------------------------------------------------------------------------- /services/file-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "file-service", 3 | "version": "0.0.1", 4 | "description": "File Service", 5 | "scripts": { 6 | "lint": "tslint 'src/**/*.ts'", 7 | "test": "jest", 8 | "test:watch": "jest --watch", 9 | "test:ci": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-test-results.xml jest --runInBand --ci", 10 | "test:e2e": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-e2e-test-results.xml jest --config=jest.config.e2e.js --runInBand --ci", 11 | "coverage": "jest --coverage", 12 | "deploy": "serverless deploy", 13 | "remove": "serverless remove", 14 | "prettier": "prettier --write src/**/*.ts", 15 | "prettier:ci": "prettier --list-different src/**/*.ts" 16 | }, 17 | "dependencies": { 18 | "aws-sdk": "^2.449.0", 19 | "node-fetch": "^3.0.0" 20 | }, 21 | "devDependencies": { 22 | "@anttiviljami/serverless-stack-output": "^0.3.1", 23 | "@types/aws-lambda": "^8.10.62", 24 | "@types/chai": "^4.2.12", 25 | "@types/jest": "^27.0.0", 26 | "@types/node": "^16.0.0", 27 | "@types/node-fetch": "^3.0.0", 28 | "aws-testing-library": "^2.0.0", 29 | "chai": "^4.2.0", 30 | "jest": "27.5.1", 31 | "serverless-plugin-tracing": "^2.0.0", 32 | "serverless-webpack": "^5.2.0", 33 | "ts-jest": "27.1.5", 34 | "ts-loader": "^9.0.0", 35 | "tslint": "^6.0.0", 36 | "tslint-config-prettier": "^1.18.0", 37 | "typescript": "^4.0.0", 38 | "webpack": "^5.0.0", 39 | "webpack-node-externals": "^3.0.0" 40 | }, 41 | "author": "Erez Rokah", 42 | "license": "MIT" 43 | } 44 | -------------------------------------------------------------------------------- /services/file-service/serverless.yml: -------------------------------------------------------------------------------- 1 | service: file-service 2 | 3 | plugins: 4 | - serverless-webpack 5 | - '@anttiviljami/serverless-stack-output' 6 | - serverless-plugin-tracing 7 | 8 | custom: 9 | defaultStage: dev 10 | currentStage: ${opt:stage, self:custom.defaultStage} 11 | currentRegion: ${file(../common/environment/config.${self:custom.currentStage}.json):region} 12 | s3Bucket: file-service-s3-bucket-${self:custom.currentStage} 13 | 14 | output: 15 | handler: ../../scripts/stackOutput.handler 16 | 17 | webpack: 18 | includeModules: true 19 | packager: yarn 20 | 21 | provider: 22 | name: aws 23 | runtime: nodejs8.10 24 | stage: ${self:custom.currentStage} 25 | region: ${self:custom.currentRegion} 26 | 27 | memorySize: 256 28 | logRetentionInDays: 7 29 | 30 | tracing: true # enable tracing 31 | iamRoleStatements: 32 | - Effect: 'Allow' # xray permissions (required) 33 | Action: 34 | - 'xray:PutTraceSegments' 35 | - 'xray:PutTelemetryRecords' 36 | Resource: 37 | - '*' 38 | 39 | - Effect: Allow 40 | Action: 41 | - s3:PutObject 42 | Resource: 'arn:aws:s3:::${self:custom.s3Bucket}/*' 43 | 44 | functions: 45 | save: 46 | handler: src/handler.save 47 | events: 48 | - http: 49 | path: save 50 | method: post 51 | cors: true 52 | environment: 53 | BUCKET: ${self:custom.s3Bucket} 54 | 55 | resources: 56 | Resources: 57 | FileServiceBucket: 58 | Type: AWS::S3::Bucket 59 | Properties: 60 | BucketName: ${self:custom.s3Bucket} 61 | AccessControl: Private 62 | -------------------------------------------------------------------------------- /services/file-service/src/fileSaver.ts: -------------------------------------------------------------------------------- 1 | import { S3 } from 'aws-sdk'; 2 | import fetch from 'node-fetch'; 3 | 4 | const Bucket = process.env.BUCKET || ''; 5 | 6 | const headers = { 7 | /* Required for cookies, authorization headers with HTTPS */ 8 | 'Access-Control-Allow-Credentials': true, 9 | /* Required for CORS support to work */ 10 | 'Access-Control-Allow-Origin': '*', 11 | }; 12 | 13 | export const saveFile = async (body: string | null) => { 14 | const s3 = new S3(); 15 | 16 | let fileUrl = ''; 17 | let key = ''; 18 | if (body) { 19 | try { 20 | const parsed = JSON.parse(body); 21 | fileUrl = parsed.file_url || ''; 22 | key = parsed.key || ''; 23 | } catch (e) { 24 | console.log('Invalid body', e); 25 | } 26 | } 27 | 28 | if (fileUrl !== '' && key !== '') { 29 | try { 30 | const fetchResponse = await fetch(fileUrl); 31 | if (fetchResponse.ok) { 32 | const buffer = await fetchResponse.buffer(); 33 | await s3 34 | .putObject({ 35 | Body: buffer, 36 | Bucket, 37 | Key: key, 38 | }) 39 | .promise(); 40 | const response = { 41 | body: JSON.stringify({ 42 | input: body, 43 | message: 'File saved', 44 | }), 45 | headers, 46 | statusCode: 200, 47 | }; 48 | return { response, error: null }; 49 | } else { 50 | const response = { 51 | body: JSON.stringify({ 52 | input: body, 53 | message: 'Fetch failed', 54 | }), 55 | headers, 56 | statusCode: 500, 57 | }; 58 | const error = new Error( 59 | `Failed to fetch ${fetchResponse.url}: ${fetchResponse.status} ${ 60 | fetchResponse.statusText 61 | }`, 62 | ); 63 | return { response, error }; 64 | } 65 | } catch (error) { 66 | console.log(error); 67 | const response = { 68 | body: JSON.stringify({ 69 | input: body, 70 | message: 'Unknown Error', 71 | }), 72 | headers, 73 | statusCode: 500, 74 | }; 75 | return { response, error }; 76 | } 77 | } else { 78 | const response = { 79 | body: JSON.stringify({ 80 | input: body, 81 | message: 'Bad input data or missing file url.', 82 | }), 83 | headers, 84 | statusCode: 422, 85 | }; 86 | return { response, error: null }; 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /services/file-service/src/handler.test.ts: -------------------------------------------------------------------------------- 1 | import { save } from './handler'; 2 | 3 | jest.mock('./fileSaver'); 4 | 5 | describe('handler', () => { 6 | const { saveFile } = require('./fileSaver'); 7 | const response = {}; 8 | const error = new Error('some error'); 9 | 10 | const payload = { 11 | file_url: 12 | 'https://assets-cdn.github.com/images/modules/open_graph/github-mark.png', 13 | key: 'github.png', 14 | }; 15 | const event = { body: JSON.stringify(payload) }; 16 | const context: any = null; 17 | 18 | beforeEach(() => { 19 | jest.clearAllMocks(); 20 | }); 21 | 22 | test('should call callback on response', async () => { 23 | saveFile.mockReturnValue(Promise.resolve({ response, error: null })); 24 | 25 | const callback = jest.fn(); 26 | 27 | await save(event, context, callback); 28 | 29 | expect(saveFile).toHaveBeenCalledTimes(1); 30 | expect(saveFile).toHaveBeenCalledWith(JSON.stringify(payload)); 31 | expect(callback).toHaveBeenCalledTimes(1); 32 | expect(callback).toHaveBeenCalledWith(null, response); 33 | }); 34 | 35 | test('should call callback on error', async () => { 36 | saveFile.mockReturnValue(Promise.resolve({ response, error })); 37 | 38 | const callback = jest.fn(); 39 | 40 | await save(event, context, callback); 41 | 42 | expect(saveFile).toHaveBeenCalledTimes(1); 43 | expect(saveFile).toHaveBeenCalledWith(JSON.stringify(payload)); 44 | expect(callback).toHaveBeenCalledTimes(1); 45 | expect(callback).toHaveBeenCalledWith(error); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /services/file-service/src/handler.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayEvent, Callback, Context, Handler } from 'aws-lambda'; 2 | import { saveFile } from './fileSaver'; 3 | 4 | export const save: Handler = ( 5 | event: APIGatewayEvent, 6 | context: Context, 7 | callback?: Callback, 8 | ) => { 9 | return saveFile(event.body).then(({ response, error }) => { 10 | if (error) { 11 | callback && callback(error); 12 | } else { 13 | callback && callback(null, response); 14 | } 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /services/file-service/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const slsw = require('serverless-webpack'); 3 | const nodeExternals = require('webpack-node-externals'); 4 | 5 | module.exports = { 6 | entry: slsw.lib.entries, 7 | target: 'node', 8 | mode: slsw.lib.webpack.isLocal ? 'development' : 'production', 9 | optimization: { 10 | // We no not want to minimize our code. 11 | minimize: false, 12 | }, 13 | performance: { 14 | // Turn off size warnings for entry points 15 | hints: false, 16 | }, 17 | devtool: 'nosources-source-map', 18 | externals: [nodeExternals()], 19 | resolve: { 20 | extensions: ['.js', '.json', '.ts', '.tsx'], 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.ts(x?)$/, 26 | use: [ 27 | { 28 | loader: 'ts-loader', 29 | }, 30 | ], 31 | }, 32 | { 33 | test: /\.mjs$/, 34 | type: 'javascript/auto', 35 | }, 36 | ], 37 | }, 38 | output: { 39 | libraryTarget: 'commonjs2', 40 | path: path.join(__dirname, '.webpack'), 41 | filename: '[name].js', 42 | sourceMapFilename: '[file].map', 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /services/kinesis-service/.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless 7 | 8 | # Webpack directories 9 | .webpack 10 | 11 | config.*.json 12 | !config.example.json 13 | 14 | coverage 15 | 16 | junit.xml 17 | 18 | out.txt -------------------------------------------------------------------------------- /services/kinesis-service/README.md: -------------------------------------------------------------------------------- 1 | # Kinesis Service 2 | 3 | ## Setup 4 | 5 | ```bash 6 | yarn install 7 | ``` 8 | -------------------------------------------------------------------------------- /services/kinesis-service/e2e/handler.chai.test.ts: -------------------------------------------------------------------------------- 1 | import awsTesting from 'aws-testing-library/lib/chai'; 2 | import { invoke } from 'aws-testing-library/lib/utils/lambda'; 3 | import chai = require('chai'); 4 | 5 | chai.use(awsTesting); 6 | 7 | const { expect } = chai; 8 | 9 | describe('kinesis service e2e tests', () => { 10 | const region = 'us-east-1'; 11 | const stream = 'kinesis-service-stream-dev'; 12 | 13 | test('should put record in stream on lambda invoke', async () => { 14 | const body = { 15 | record: { message: 'message from test' }, 16 | }; 17 | 18 | const result = await invoke(region, 'kinesis-service-dev-queue', { 19 | body: JSON.stringify(body), 20 | }); 21 | 22 | const { data } = JSON.parse(result.body); 23 | 24 | expect(data.message).to.be.equal('Record saved'); 25 | await expect({ region, stream }).to.have.record(({ record }) => { 26 | return record.id === data.id && record.message === body.record.message; 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /services/kinesis-service/e2e/handler.test.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from 'aws-testing-library/lib/utils/lambda'; 2 | 3 | describe('kinesis service e2e tests', () => { 4 | const region = 'us-east-1'; 5 | const stream = 'kinesis-service-stream-dev'; 6 | 7 | test('should put record in stream on lambda invoke', async () => { 8 | const body = { 9 | record: { message: 'message from test' }, 10 | }; 11 | 12 | const result = await invoke(region, 'kinesis-service-dev-queue', { 13 | body: JSON.stringify(body), 14 | }); 15 | 16 | const { data } = JSON.parse(result.body); 17 | 18 | expect.assertions(2); 19 | expect(data.message).toEqual('Record saved'); 20 | await expect({ region, stream }).toHaveRecord(({ record }) => { 21 | return record.id === data.id && record.message === body.record.message; 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /services/kinesis-service/e2e/setup.ts: -------------------------------------------------------------------------------- 1 | import { deploy } from 'aws-testing-library/lib/utils/serverless'; 2 | 3 | const deployDev = async () => { 4 | await deploy('dev'); 5 | }; 6 | 7 | export default deployDev; 8 | -------------------------------------------------------------------------------- /services/kinesis-service/e2e/setupFrameworks.ts: -------------------------------------------------------------------------------- 1 | import 'aws-testing-library/lib/jest'; 2 | -------------------------------------------------------------------------------- /services/kinesis-service/e2e/setup_hook.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register'); 2 | module.exports = require('./setup').default; 3 | -------------------------------------------------------------------------------- /services/kinesis-service/jest.config.e2e.js: -------------------------------------------------------------------------------- 1 | const config = require('./jest.config'); 2 | 3 | module.exports = { 4 | ...config, 5 | roots: ['/e2e'], 6 | globalSetup: '/e2e/setup_hook.js', 7 | setupTestFrameworkScriptFile: '/e2e/setupFrameworks.ts', 8 | }; 9 | -------------------------------------------------------------------------------- /services/kinesis-service/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | reporters: ['default', 'jest-junit'], 9 | testEnvironment: 'node', 10 | }; 11 | -------------------------------------------------------------------------------- /services/kinesis-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kinesis-service", 3 | "version": "0.0.1", 4 | "description": "Kinesis Service", 5 | "scripts": { 6 | "lint": "tslint 'src/**/*.ts'", 7 | "test": "jest", 8 | "test:watch": "jest --watch", 9 | "test:ci": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-test-results.xml jest --runInBand --ci", 10 | "test:e2e": "JEST_JUNIT_OUTPUT=../../reports/junit/${npm_package_name}-e2e-test-results.xml jest --config=jest.config.e2e.js --runInBand --ci", 11 | "coverage": "jest --coverage", 12 | "deploy": "serverless deploy", 13 | "remove": "serverless remove", 14 | "prettier": "prettier --write src/**/*.ts", 15 | "prettier:ci": "prettier --list-different src/**/*.ts" 16 | }, 17 | "dependencies": { 18 | "aws-sdk": "^2.449.0", 19 | "uuid": "^8.0.0" 20 | }, 21 | "devDependencies": { 22 | "@anttiviljami/serverless-stack-output": "^0.3.1", 23 | "@types/aws-lambda": "^8.10.62", 24 | "@types/chai": "^4.2.12", 25 | "@types/jest": "^27.0.0", 26 | "@types/node": "^16.0.0", 27 | "@types/node-fetch": "^3.0.0", 28 | "aws-testing-library": "^2.0.0", 29 | "chai": "^4.2.0", 30 | "jest": "27.5.1", 31 | "mockdate": "^3.0.0", 32 | "serverless-plugin-tracing": "^2.0.0", 33 | "serverless-webpack": "^5.2.0", 34 | "ts-jest": "27.1.5", 35 | "ts-loader": "^9.0.0", 36 | "tslint": "^6.0.0", 37 | "tslint-config-prettier": "^1.18.0", 38 | "typescript": "^4.0.0", 39 | "webpack": "^5.0.0", 40 | "webpack-node-externals": "^3.0.0" 41 | }, 42 | "author": "Erez Rokah", 43 | "license": "MIT" 44 | } 45 | -------------------------------------------------------------------------------- /services/kinesis-service/serverless.yml: -------------------------------------------------------------------------------- 1 | service: kinesis-service 2 | 3 | plugins: 4 | - serverless-webpack 5 | - '@anttiviljami/serverless-stack-output' 6 | - serverless-plugin-tracing 7 | 8 | custom: 9 | defaultStage: dev 10 | currentStage: ${opt:stage, self:custom.defaultStage} 11 | currentRegion: ${file(../common/environment/config.${self:custom.currentStage}.json):region} 12 | kinesisStream: kinesis-service-stream-${self:custom.currentStage} 13 | 14 | output: 15 | handler: ../../scripts/stackOutput.handler 16 | 17 | webpack: 18 | includeModules: true 19 | packager: yarn 20 | 21 | provider: 22 | name: aws 23 | runtime: nodejs8.10 24 | stage: ${self:custom.currentStage} 25 | region: ${self:custom.currentRegion} 26 | 27 | memorySize: 256 28 | logRetentionInDays: 7 29 | 30 | tracing: true # enable tracing 31 | iamRoleStatements: 32 | - Effect: 'Allow' # xray permissions (required) 33 | Action: 34 | - 'xray:PutTraceSegments' 35 | - 'xray:PutTelemetryRecords' 36 | Resource: 37 | - '*' 38 | 39 | - Effect: Allow 40 | Action: 41 | - kinesis:PutRecord 42 | Resource: 'arn:aws:kinesis:${opt:region, self:provider.region}:*:stream/${self:custom.kinesisStream}' 43 | 44 | functions: 45 | queue: 46 | handler: src/handler.queue 47 | events: 48 | - http: 49 | path: queue 50 | method: post 51 | cors: true 52 | environment: 53 | Stream: ${self:custom.kinesisStream} 54 | 55 | resources: 56 | Resources: 57 | KinesisStream: 58 | Type: AWS::Kinesis::Stream 59 | Properties: 60 | Name: ${self:custom.kinesisStream} 61 | ShardCount: 1 62 | -------------------------------------------------------------------------------- /services/kinesis-service/src/handler.test.ts: -------------------------------------------------------------------------------- 1 | import { queue } from './handler'; 2 | 3 | jest.mock('./kinesis'); 4 | 5 | describe('handler', () => { 6 | const { queueEvent } = require('./kinesis'); 7 | const response = {}; 8 | const error = new Error('some error'); 9 | 10 | const payload = { 11 | message: 'some message', 12 | }; 13 | const event = { body: JSON.stringify(payload) }; 14 | const context: any = null; 15 | 16 | beforeEach(() => { 17 | jest.clearAllMocks(); 18 | }); 19 | 20 | test('should call callback on response', async () => { 21 | queueEvent.mockReturnValue(Promise.resolve({ response, error: null })); 22 | 23 | const callback = jest.fn(); 24 | 25 | await queue(event, context, callback); 26 | 27 | expect(queueEvent).toHaveBeenCalledTimes(1); 28 | expect(queueEvent).toHaveBeenCalledWith(JSON.stringify(payload)); 29 | expect(callback).toHaveBeenCalledTimes(1); 30 | expect(callback).toHaveBeenCalledWith(null, response); 31 | }); 32 | 33 | test('should call callback on error', async () => { 34 | queueEvent.mockReturnValue(Promise.resolve({ response, error })); 35 | 36 | const callback = jest.fn(); 37 | 38 | await queue(event, context, callback); 39 | 40 | expect(queueEvent).toHaveBeenCalledTimes(1); 41 | expect(queueEvent).toHaveBeenCalledWith(JSON.stringify(payload)); 42 | expect(callback).toHaveBeenCalledTimes(1); 43 | expect(callback).toHaveBeenCalledWith(error); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /services/kinesis-service/src/handler.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayEvent, Callback, Context, Handler } from 'aws-lambda'; 2 | import { queueEvent } from './kinesis'; 3 | 4 | export const queue: Handler = ( 5 | event: APIGatewayEvent, 6 | context: Context, 7 | callback?: Callback, 8 | ) => { 9 | return queueEvent(event.body).then(({ response, error }) => { 10 | if (error) { 11 | callback && callback(error); 12 | } else { 13 | callback && callback(null, response); 14 | } 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /services/kinesis-service/src/kinesis.test.ts: -------------------------------------------------------------------------------- 1 | import MockDate = require('mockdate'); 2 | 3 | jest.mock('aws-sdk', () => { 4 | const promise = jest.fn(); 5 | const putRecord = jest.fn(() => ({ promise })); 6 | const Kinesis = jest.fn(() => ({ putRecord })); 7 | return { Kinesis }; 8 | }); 9 | jest.mock('uuid', () => { 10 | const v1 = jest.fn(() => '00000000000000000000000000'); 11 | return { v1 }; 12 | }); 13 | jest.spyOn(console, 'log'); 14 | 15 | process.env.Stream = 'Stream'; 16 | 17 | MockDate.set('1948/1/1'); 18 | 19 | describe('kinesis', () => { 20 | const headers = { 21 | 'Access-Control-Allow-Credentials': true, 22 | 'Access-Control-Allow-Origin': '*', 23 | }; 24 | 25 | beforeEach(() => { 26 | jest.clearAllMocks(); 27 | }); 28 | 29 | const badDataResponse = (input: any) => ({ 30 | body: JSON.stringify({ 31 | input, 32 | message: 'Bad input data', 33 | }), 34 | headers, 35 | statusCode: 422, 36 | }); 37 | 38 | const AWS = require('aws-sdk'); 39 | const kinesis = AWS.Kinesis; 40 | const { v1: uuid } = require('uuid'); 41 | 42 | test('should return response on empty body', async () => { 43 | const { queueEvent } = require('./kinesis'); 44 | 45 | const body = ''; 46 | 47 | const { response, error } = await queueEvent(body); 48 | 49 | expect(kinesis).toHaveBeenCalledTimes(1); 50 | expect(response).toEqual(badDataResponse(body)); 51 | expect(error).toBeNull(); 52 | }); 53 | 54 | test('should return response on malformed body', async () => { 55 | const { queueEvent } = require('./kinesis'); 56 | 57 | const body = 'invalid-json'; 58 | 59 | const { response, error } = await queueEvent(body); 60 | 61 | expect(kinesis).toHaveBeenCalledTimes(1); 62 | expect(response).toEqual(badDataResponse(body)); 63 | expect(error).toBeNull(); 64 | expect(console.log).toHaveBeenCalledTimes(1); 65 | expect(console.log).toHaveBeenCalledWith( 66 | 'Invalid body', 67 | expect.any(SyntaxError), 68 | ); 69 | }); 70 | 71 | test('should return response on invalid record', async () => { 72 | const { queueEvent } = require('./kinesis'); 73 | 74 | const record = { message: [] }; 75 | const body = JSON.stringify({ record }); 76 | 77 | const { response, error } = await queueEvent(body); 78 | 79 | expect(kinesis).toHaveBeenCalledTimes(1); 80 | expect(response).toEqual(badDataResponse(body)); 81 | expect(error).toBeNull(); 82 | }); 83 | 84 | test('should return response on valid input', async () => { 85 | const { queueEvent } = require('./kinesis'); 86 | 87 | const record = { message: 'some message' }; 88 | const body = JSON.stringify({ record }); 89 | 90 | const { response, error } = await queueEvent(body); 91 | 92 | expect(uuid).toHaveBeenCalledTimes(1); 93 | expect(AWS.Kinesis().putRecord).toHaveBeenCalledTimes(1); 94 | expect(AWS.Kinesis().putRecord).toHaveBeenCalledWith({ 95 | Data: JSON.stringify({ 96 | record: { message: record.message, id: uuid() }, 97 | timestamp: new Date().toISOString(), 98 | }), 99 | PartitionKey: uuid(), 100 | StreamName: process.env.Stream, 101 | }); 102 | expect(AWS.Kinesis().putRecord().promise).toHaveBeenCalledTimes(1); 103 | 104 | expect(response).toEqual({ 105 | body: JSON.stringify({ 106 | data: { message: 'Record saved', id: uuid() }, 107 | input: body, 108 | }), 109 | headers, 110 | statusCode: 200, 111 | }); 112 | expect(error).toBeNull(); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /services/kinesis-service/src/kinesis.ts: -------------------------------------------------------------------------------- 1 | import { Kinesis } from 'aws-sdk'; 2 | import { v1 as uuid } from 'uuid'; 3 | 4 | const StreamName = process.env.Stream || ''; 5 | 6 | interface IRecord { 7 | message: string; 8 | } 9 | 10 | const headers = { 11 | /* Required for cookies, authorization headers with HTTPS */ 12 | 'Access-Control-Allow-Credentials': true, 13 | /* Required for CORS support to work */ 14 | 'Access-Control-Allow-Origin': '*', 15 | }; 16 | 17 | const isString = (x: any): x is string => typeof x === 'string'; 18 | 19 | const isRecord = (item: any) => { 20 | if (isString(item.message)) { 21 | return true; 22 | } 23 | return false; 24 | }; 25 | 26 | export const queueEvent = async (body: string | null) => { 27 | const kinesis = new Kinesis(); 28 | 29 | let item = null; 30 | if (body) { 31 | try { 32 | const parsed = JSON.parse(body); 33 | item = parsed.record as IRecord; 34 | } catch (e) { 35 | console.log('Invalid body', e); 36 | } 37 | } 38 | 39 | if (item && isRecord(item)) { 40 | const record = { message: item.message, id: uuid() }; 41 | const params = { 42 | Data: JSON.stringify({ 43 | record, 44 | timestamp: new Date().toISOString(), 45 | }), 46 | PartitionKey: record.id, 47 | StreamName, 48 | }; 49 | await kinesis.putRecord(params).promise(); 50 | const response = { 51 | body: JSON.stringify({ 52 | data: { message: 'Record saved', id: record.id }, 53 | input: body, 54 | }), 55 | headers, 56 | statusCode: 200, 57 | }; 58 | return { response, error: null }; 59 | } else { 60 | const response = { 61 | body: JSON.stringify({ 62 | input: body, 63 | message: 'Bad input data', 64 | }), 65 | headers, 66 | statusCode: 422, 67 | }; 68 | return { response, error: null }; 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /services/kinesis-service/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const slsw = require('serverless-webpack'); 3 | const nodeExternals = require('webpack-node-externals'); 4 | 5 | module.exports = { 6 | entry: slsw.lib.entries, 7 | target: 'node', 8 | mode: slsw.lib.webpack.isLocal ? 'development' : 'production', 9 | optimization: { 10 | // We no not want to minimize our code. 11 | minimize: false, 12 | }, 13 | performance: { 14 | // Turn off size warnings for entry points 15 | hints: false, 16 | }, 17 | devtool: 'nosources-source-map', 18 | externals: [nodeExternals()], 19 | resolve: { 20 | extensions: ['.js', '.json', '.ts', '.tsx'], 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.ts(x?)$/, 26 | use: [ 27 | { 28 | loader: 'ts-loader', 29 | }, 30 | ], 31 | }, 32 | { 33 | test: /\.mjs$/, 34 | type: 'javascript/auto', 35 | }, 36 | ], 37 | }, 38 | output: { 39 | libraryTarget: 'commonjs2', 40 | path: path.join(__dirname, '.webpack'), 41 | filename: '[name].js', 42 | sourceMapFilename: '[file].map', 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /services/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "lib": ["esnext", "dom"], 5 | "module": "commonjs", 6 | "target": "es2017", 7 | "forceConsistentCasingInFileNames": true, 8 | "noImplicitReturns": true, 9 | "strict": true, 10 | "noUnusedLocals": true 11 | }, 12 | "exclude": ["node_modules", "**/*.test.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /services/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-config-prettier"], 3 | "rules": { 4 | "no-unused-expression": [true, "allow-fast-null-checks"], 5 | "no-console": false 6 | } 7 | } 8 | --------------------------------------------------------------------------------