diff --git a/.env.sample b/.env.sample index 84e54344..827dba26 100644 --- a/.env.sample +++ b/.env.sample @@ -49,7 +49,7 @@ GITHUB_CLIENT_ID=YOUR_GITHUB_CLIENT_ID GITHUB_SECRET=YOUR_GITHUB_CLIENT_SECRET GITHUB_CALLBACK_URL=http://localhost:5000/callback/github -#Google Analytics Related settings +# Google Analytics Related settings GOOGLE_OAUTH_KEY_PATH=PATH_TO_SERVICE_ACCOUNT_KEY GA_PAGE_SIZE=10000 GA_WEB_VIEW_ID=GA_WEB_VIEW_ID @@ -64,3 +64,5 @@ URL_RESOLVER_URL=http://localhost:4000 ENGINE_API_KEY= WEB_CONCURRENCY=2 + +JEST_TIMEOUT=5000 \ No newline at end of file diff --git a/.gitignore b/.gitignore index fe2e55c9..efafc114 100644 --- a/.gitignore +++ b/.gitignore @@ -38,9 +38,10 @@ jspm_packages # Optional REPL history .node_repl_history -config/local-* - # docker-compose test data esdata -.env \ No newline at end of file +# local config +config/local-* +.vscode/ +.env diff --git a/package-lock.json b/package-lock.json index 430fbf51..851a5ff5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5155,6 +5155,15 @@ "zen-observable-ts": "^0.8.20" } }, + "apollo-reporting-protobuf": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/apollo-reporting-protobuf/-/apollo-reporting-protobuf-0.6.0.tgz", + "integrity": "sha512-AFLQIuO0QhkoCF+41Be/B/YU0C33BZ0opfyXorIjM3MNNiEDSyjZqmUozlB3LqgfhT9mn2IR5RSsA+1b4VovDQ==", + "dev": true, + "requires": { + "@apollo/protobufjs": "^1.0.3" + } + }, "apollo-server-caching": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.5.1.tgz", @@ -5328,6 +5337,208 @@ "apollo-server-types": "^0.3.0" } }, + "apollo-server-testing": { + "version": "2.18.2", + "resolved": "https://registry.npmjs.org/apollo-server-testing/-/apollo-server-testing-2.18.2.tgz", + "integrity": "sha512-4qoJ8AYUKolqV397G7XXRjOCM1ADQB66ReL/B5NxdoROWTvxIOW2m1NOrmvMX1w6AmpSUD5APRM27I7fC3f65g==", + "dev": true, + "requires": { + "apollo-server-core": "^2.18.2" + }, + "dependencies": { + "@apollographql/graphql-playground-html": { + "version": "1.6.26", + "resolved": "https://registry.npmjs.org/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.26.tgz", + "integrity": "sha512-XAwXOIab51QyhBxnxySdK3nuMEUohhDsHQ5Rbco/V1vjlP75zZ0ZLHD9dTpXTN8uxKxopb2lUvJTq+M4g2Q0HQ==", + "dev": true, + "requires": { + "xss": "^1.0.6" + } + }, + "@types/node-fetch": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.7.tgz", + "integrity": "sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw==", + "dev": true, + "requires": { + "@types/node": "*", + "form-data": "^3.0.0" + } + }, + "@types/ws": { + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.2.7.tgz", + "integrity": "sha512-UUFC/xxqFLP17hTva8/lVT0SybLUrfSD9c+iapKb0fEiC8uoDbA+xuZ3pAN603eW+bY8ebSMLm9jXdIPnD0ZgA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "apollo-cache-control": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.11.3.tgz", + "integrity": "sha512-21GCeC9AIIa22uD0Vtqn/N0D5kOB4rY/Pa9aQhxVeLN+4f8Eu4nmteXhFypUD0LL1/58dmm8lS5embsfoIGjEA==", + "dev": true, + "requires": { + "apollo-server-env": "^2.4.5", + "apollo-server-plugin-base": "^0.10.1" + } + }, + "apollo-datasource": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/apollo-datasource/-/apollo-datasource-0.7.2.tgz", + "integrity": "sha512-ibnW+s4BMp4K2AgzLEtvzkjg7dJgCaw9M5b5N0YKNmeRZRnl/I/qBTQae648FsRKgMwTbRQIvBhQ0URUFAqFOw==", + "dev": true, + "requires": { + "apollo-server-caching": "^0.5.2", + "apollo-server-env": "^2.4.5" + } + }, + "apollo-env": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/apollo-env/-/apollo-env-0.6.5.tgz", + "integrity": "sha512-jeBUVsGymeTHYWp3me0R2CZRZrFeuSZeICZHCeRflHTfnQtlmbSXdy5E0pOyRM9CU4JfQkKDC98S1YglQj7Bzg==", + "dev": true, + "requires": { + "@types/node-fetch": "2.5.7", + "core-js": "^3.0.1", + "node-fetch": "^2.2.0", + "sha.js": "^2.4.11" + } + }, + "apollo-graphql": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/apollo-graphql/-/apollo-graphql-0.6.0.tgz", + "integrity": "sha512-BxTf5LOQe649e9BNTPdyCGItVv4Ll8wZ2BKnmiYpRAocYEXAVrQPWuSr3dO4iipqAU8X0gvle/Xu9mSqg5b7Qg==", + "dev": true, + "requires": { + "apollo-env": "^0.6.5", + "lodash.sortby": "^4.7.0" + } + }, + "apollo-server-caching": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.5.2.tgz", + "integrity": "sha512-HUcP3TlgRsuGgeTOn8QMbkdx0hLPXyEJehZIPrcof0ATz7j7aTPA4at7gaiFHCo8gk07DaWYGB3PFgjboXRcWQ==", + "dev": true, + "requires": { + "lru-cache": "^5.0.0" + } + }, + "apollo-server-core": { + "version": "2.18.2", + "resolved": "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.18.2.tgz", + "integrity": "sha512-phz57BFBukMa3Ta7ZVW7pj1pdUne9KYLbcBdEcITr+I0+nbhy+YM8gcgpOnjrokWYiEZgIe52XeM3m4BMLw5dg==", + "dev": true, + "requires": { + "@apollographql/apollo-tools": "^0.4.3", + "@apollographql/graphql-playground-html": "1.6.26", + "@types/graphql-upload": "^8.0.0", + "@types/ws": "^7.0.0", + "apollo-cache-control": "^0.11.3", + "apollo-datasource": "^0.7.2", + "apollo-graphql": "^0.6.0", + "apollo-reporting-protobuf": "^0.6.0", + "apollo-server-caching": "^0.5.2", + "apollo-server-env": "^2.4.5", + "apollo-server-errors": "^2.4.2", + "apollo-server-plugin-base": "^0.10.1", + "apollo-server-types": "^0.6.0", + "apollo-tracing": "^0.11.4", + "async-retry": "^1.2.1", + "fast-json-stable-stringify": "^2.0.0", + "graphql-extensions": "^0.12.5", + "graphql-tag": "^2.9.2", + "graphql-tools": "^4.0.0", + "graphql-upload": "^8.0.2", + "loglevel": "^1.6.7", + "lru-cache": "^5.0.0", + "sha.js": "^2.4.11", + "subscriptions-transport-ws": "^0.9.11", + "uuid": "^8.0.0", + "ws": "^6.0.0" + } + }, + "apollo-server-env": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.5.tgz", + "integrity": "sha512-nfNhmGPzbq3xCEWT8eRpoHXIPNcNy3QcEoBlzVMjeglrBGryLG2LXwBSPnVmTRRrzUYugX0ULBtgE3rBFNoUgA==", + "dev": true, + "requires": { + "node-fetch": "^2.1.2", + "util.promisify": "^1.0.0" + } + }, + "apollo-server-errors": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/apollo-server-errors/-/apollo-server-errors-2.4.2.tgz", + "integrity": "sha512-FeGxW3Batn6sUtX3OVVUm7o56EgjxDlmgpTLNyWcLb0j6P8mw9oLNyAm3B+deHA4KNdNHO5BmHS2g1SJYjqPCQ==", + "dev": true + }, + "apollo-server-plugin-base": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/apollo-server-plugin-base/-/apollo-server-plugin-base-0.10.1.tgz", + "integrity": "sha512-XChCBDNyfByWqVXptsjPwrwrCj5cxMmNbchZZi8KXjtJ0hN2C/9BMNlInJd6bVGXvUbkRJYUakfKCfO5dZmwIg==", + "dev": true, + "requires": { + "apollo-server-types": "^0.6.0" + } + }, + "apollo-server-types": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.6.0.tgz", + "integrity": "sha512-usqXaz81bHxD2IZvKEQNnLpSbf2Z/BmobXZAjEefJEQv1ItNn+lJNUmSSEfGejHvHlg2A7WuAJKJWyDWcJrNnA==", + "dev": true, + "requires": { + "apollo-reporting-protobuf": "^0.6.0", + "apollo-server-caching": "^0.5.2", + "apollo-server-env": "^2.4.5" + } + }, + "apollo-tracing": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.11.4.tgz", + "integrity": "sha512-zBu/SwQlXfbdpcKLzWARGVjrEkIZUW3W9Mb4CCIzv07HbBQ8IQpmf9w7HIJJefC7rBiBJYg6JBGyuro3N2lxCA==", + "dev": true, + "requires": { + "apollo-server-env": "^2.4.5", + "apollo-server-plugin-base": "^0.10.1" + } + }, + "core-js": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", + "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", + "dev": true + }, + "graphql-extensions": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/graphql-extensions/-/graphql-extensions-0.12.5.tgz", + "integrity": "sha512-mGyGaktGpK3TVBtM0ZoyPX6Xk0mN9GYX9DRyFzDU4k4A2w93nLX7Ebcp+9/O5nHRmgrc0WziYYSmoWq2WNIoUQ==", + "dev": true, + "requires": { + "@apollographql/apollo-tools": "^0.4.3", + "apollo-server-env": "^2.4.5", + "apollo-server-types": "^0.6.0" + } + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "uuid": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", + "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", + "dev": true + } + } + }, "apollo-server-types": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.3.0.tgz", @@ -6611,6 +6822,12 @@ "which": "^1.2.9" } }, + "cssfilter": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", + "integrity": "sha1-xtJnJjKi5cg+AT5oZKQs6N79IK4=", + "dev": true + }, "cssom": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", @@ -12017,6 +12234,12 @@ "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.7.tgz", "integrity": "sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg==" }, + "loglevel": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.7.0.tgz", + "integrity": "sha512-i2sY04nal5jDcagM3FMfG++T69GEEM8CYuOfeOIvmXzOIcwE9a/CJPR0MFM97pYMj/u10lzz7/zd7+qwhrBTqQ==", + "dev": true + }, "long": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", @@ -15857,6 +16080,24 @@ "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz", "integrity": "sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM=" }, + "xss": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.8.tgz", + "integrity": "sha512-3MgPdaXV8rfQ/pNn16Eio6VXYPTkqwa0vc7GkiymmY/DqR1SE/7VPAAVZz1GJsJFrllMYO3RHfEaiUGjab6TNw==", + "dev": true, + "requires": { + "commander": "^2.20.3", + "cssfilter": "0.0.10" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + } + } + }, "xtraverse": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/xtraverse/-/xtraverse-0.1.0.tgz", diff --git a/package.json b/package.json index b70cd9e9..e0fa6a42 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "seed": "cd test/rumors-db && npm run seed", "pretest": "npm run rumors-db:test", "test": "NODE_ENV=test ELASTICSEARCH_URL=http://localhost:62223 jest --runInBand", - "start": "pm2 startOrRestart ecosystem.config.js --env production --no-daemon", + "posttest": "NODE_ENV=test ELASTICSEARCH_URL=http://localhost:62223 babel-node test/postTest.js", + "start": "pm2 startOrRestart ecosystem.config.js --env production --no-daemon", "lint": "eslint src/.", "lint:fix": "eslint --fix src/.", "rumors-db:pull": "cd test/rumors-db && git pull", @@ -62,6 +63,7 @@ "@babel/plugin-syntax-import-meta": "^7.8.0", "@babel/polyfill": "^7.8.0", "@babel/preset-env": "^7.8.0", + "apollo-server-testing": "^2.18.2", "babel-eslint": "^10.0.3", "babel-jest": "^26.0.0", "babel-plugin-module-resolver": "^4.0.0", @@ -82,7 +84,8 @@ ], "setupFilesAfterEnv": [ "./test/setup.js" - ] + ], + "testSequencer": "./test/testSequencer.js" }, "engines": { "node": ">=12" diff --git a/src/__fixtures__/index.js b/src/__fixtures__/index.js new file mode 100644 index 00000000..a6e2a4a5 --- /dev/null +++ b/src/__fixtures__/index.js @@ -0,0 +1,11 @@ +export default { + '/users/doc/6LOqD_3gpe4ZVaxRvemf7KNTfm6y3WNBu1hbs-5MRdSWiWVss': { + name: 'test user 2', + appUserId: 'testUser2', + appId: 'TEST_BACKEND', + }, + '/users/doc/testUser1': { + name: 'test user 1', + appId: 'WEBSITE', + }, +}; diff --git a/src/__tests__/auth.js b/src/__tests__/auth.js index 525e97de..c42d923e 100644 --- a/src/__tests__/auth.js +++ b/src/__tests__/auth.js @@ -2,12 +2,13 @@ import MockDate from 'mockdate'; import { loadFixtures, unloadFixtures } from 'util/fixtures'; import fixtures from '../__fixtures__/auth'; import { verifyProfile } from '../auth'; +import client from 'util/client'; const FIXED_DATE = 612921600000; describe('verifyProfile', () => { - beforeEach(() => loadFixtures(fixtures)); - afterEach(() => unloadFixtures(fixtures)); + beforeAll(() => loadFixtures(fixtures)); + afterAll(() => unloadFixtures(fixtures)); it('authenticates user via profile ID', async () => { const passportProfile = { @@ -48,7 +49,11 @@ describe('verifyProfile', () => { 'facebookId' ); MockDate.reset(); - + await client.delete({ + index: 'users', + type: 'doc', + id: id, + }); expect(newUser).toMatchSnapshot(); }); }); diff --git a/src/__tests__/index.js b/src/__tests__/index.js new file mode 100644 index 00000000..f60fa272 --- /dev/null +++ b/src/__tests__/index.js @@ -0,0 +1,129 @@ +import { loadFixtures, unloadFixtures } from 'util/fixtures'; +import client from 'util/client'; +import { apolloServer } from '../index'; +import fixtures from '../__fixtures__/index'; +import { createTestClient } from 'apollo-server-testing'; +import MockDate from 'mockdate'; + +jest.mock('koa', () => { + const Koa = jest.requireActual('koa'); + Koa.prototype.listen = () => null; + return { + default: Koa, + __esModule: true, + }; +}); + +describe('apolloServer', () => { + const actualGraphQLServerOptions = apolloServer.graphQLServerOptions; + const mockGraphQLServerOptions = ctx => async () => + actualGraphQLServerOptions.call(apolloServer, { ctx }); + let now; + + const getCurrentUser = async (ctx = {}) => { + apolloServer.graphQLServerOptions = mockGraphQLServerOptions(ctx); + + const testClient = createTestClient(apolloServer); + const { + data: { GetUser: res }, + errors, + } = await testClient.query({ + query: `{ + GetUser { + id + appId + appUserId + name + lastActiveAt + } + }`, + }); + return { res, errors }; + }; + + beforeAll(async () => { + MockDate.set(1602288000000); + now = new Date().toISOString(); + + await loadFixtures(fixtures); + }); + + afterAll(async () => { + MockDate.reset(); + + await unloadFixtures(fixtures); + await client.delete({ + index: 'users', + type: 'doc', + id: '6LOqD_gsUWLlGviSA4KFdKpsNncQfTYeueOl-DGx9fL6zCNeA', + }); + }); + + it('gracefully handles no auth request', async () => { + const { res, errors } = await getCurrentUser(); + expect(errors).toBeUndefined(); + expect(res).toBe(null); + }); + + it('resolves current web user', async () => { + const appId = 'WEBSITE'; + const userId = 'testUser1'; + + const { res, errors } = await getCurrentUser({ + appId, + userId, + state: { user: { id: userId } }, + query: { userId }, + }); + + expect(errors).toBeUndefined(); + expect(res).toMatchObject({ + id: 'testUser1', + name: 'test user 1', + appId: 'WEBSITE', + lastActiveAt: now, + }); + }); + + it('resolves current backend user', async () => { + const appId = 'TEST_BACKEND'; + const userId = 'testUser2'; + + const { res, errors } = await getCurrentUser({ + appId, + userId, + state: {}, + query: { userId }, + }); + + expect(errors).toBeUndefined(); + expect(res).toMatchObject({ + id: '6LOqD_3gpe4ZVaxRvemf7KNTfm6y3WNBu1hbs-5MRdSWiWVss', + name: 'test user 2', + appId: 'TEST_BACKEND', + appUserId: 'testUser2', + lastActiveAt: now, + }); + }); + + it('creates new backend user if not existed', async () => { + const appId = 'TEST_BACKEND'; + const userId = 'testUser3'; + + const { res, errors } = await getCurrentUser({ + appId, + userId, + state: {}, + query: { userId }, + }); + + expect(errors).toBeUndefined(); + expect(res).toMatchObject({ + id: '6LOqD_gsUWLlGviSA4KFdKpsNncQfTYeueOl-DGx9fL6zCNeA', + name: expect.any(String), + appId: 'TEST_BACKEND', + appUserId: 'testUser3', + lastActiveAt: now, + }); + }); +}); diff --git a/src/auth.js b/src/auth.js index e1828ace..848a6ad0 100644 --- a/src/auth.js +++ b/src/auth.js @@ -15,14 +15,9 @@ passport.serializeUser((user, done) => { /** * De-serialize and populates ctx.state.user */ -passport.deserializeUser(async (userId, done) => { +passport.deserializeUser((userId, done) => { try { - const { body: user } = await client.get({ - index: 'users', - type: 'doc', - id: userId, - }); - done(null, processMeta(user)); + done(null, { userId }); } catch (err) { done(err); } @@ -31,7 +26,7 @@ passport.deserializeUser(async (userId, done) => { /** * Common verify callback for all login strategies. * It tries first authenticating user with profile.id agains fieldName in DB. - * If not applicatble, search for existing users with their email. + * If not applicable, search for existing users with their email. * If still not applicable, create a user with currently given profile. * * @param {object} profile - passport profile object @@ -222,7 +217,10 @@ export const authRouter = Router() await next(); let basePath = ''; - if (ctx.session.appId === 'RUMORS_SITE') { + if ( + ctx.session.appId === 'RUMORS_SITE' || + ctx.session.appId === 'DEVELOPMENT_FRONTEND' + ) { const allowedOrigins = process.env.RUMORS_SITE_CORS_ORIGIN.split(','); basePath = allowedOrigins.find(o => o === ctx.session.origin); } diff --git a/src/graphql/dataLoaders/__tests__/analyticsLoaderFactory.js b/src/graphql/dataLoaders/__tests__/analyticsLoaderFactory.js index e0bfb364..b91ab599 100644 --- a/src/graphql/dataLoaders/__tests__/analyticsLoaderFactory.js +++ b/src/graphql/dataLoaders/__tests__/analyticsLoaderFactory.js @@ -7,7 +7,7 @@ const loader = new DataLoaders(); MockDate.set(1578589200000); // 2020-01-10T01:00:00.000+08:00 describe('analyticsLoaderFactory', () => { - beforeAll(async () => await loadFixtures(fixtures)); + beforeAll(() => loadFixtures(fixtures)); it('should load last 31 days of data for given id', async () => { const res = await loader.analyticsLoader.load({ @@ -64,5 +64,5 @@ describe('analyticsLoaderFactory', () => { expect(error).toBe('docType is required'); }); - afterAll(async () => await unloadFixtures(fixtures)); + afterAll(() => unloadFixtures(fixtures)); }); diff --git a/src/graphql/models/User.js b/src/graphql/models/User.js index 13fab6e3..964f5e66 100644 --- a/src/graphql/models/User.js +++ b/src/graphql/models/User.js @@ -5,88 +5,7 @@ import { GraphQLNonNull, } from 'graphql'; import crypto from 'crypto'; -import { - adjectives, - names, - towns, - separators, - decorators, -} from 'util/pseudonymDict'; -import { - accessories, - faces, - facialHairStyles, - hairStyles, - bustPoses, -} from 'util/openPeepsOptions'; -import { sample, random } from 'lodash'; - -/** - * Generates a pseudonym. - */ -export const generatePseudonym = () => { - const [adj, name, place, separator, decorator] = [ - adjectives, - names, - towns, - separators, - decorators, - ].map(ary => sample(ary)); - return decorator(separator({ adj, name, place })); -}; - -export const AvatarTypes = { - OpenPeeps: 'OpenPeeps', -}; - -/** - * Generates data for open peeps avatar. - */ -export const generateOpenPeepsAvatar = () => { - const accessory = random() ? sample(accessories) : 'None'; - const facialHair = random() ? sample(facialHairStyles) : 'None'; - const flip = !!random(); - const backgroundColorIndex = random(0, 1, true); - - const face = sample(faces); - const hair = sample(hairStyles); - const body = sample(bustPoses); - - return { - accessory, - body, - face, - hair, - facialHair, - backgroundColorIndex, - flip, - }; -}; - -export const encodeAppId = appId => - crypto - .createHash('md5') - .update(appId) - .digest('base64') - .replace(/[+/]/g, '') - .substr(0, 5); -export const sha256 = value => - crypto - .createHash('sha256') - .update(value) - .digest('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - -/** - * @param {string} appUserId - user ID given by an backend app - * @param {string} appId - app ID - * @returns {string} the id used to index `user` in db - */ -export const convertAppUserIdToUserId = (appId, appUserId) => { - return `${encodeAppId(appId)}_${sha256(appUserId)}`; -}; +import { getUserId } from 'util/user'; /** * Field config helper for current user only field. @@ -95,11 +14,11 @@ export const convertAppUserIdToUserId = (appId, appUserId) => { * @param {GraphQLScalarType | GraphQLObjectType} type * @param {function?} resolver - Use default resolver if not given. */ -const currentUserOnlyField = (type, resolver) => ({ +export const currentUserOnlyField = (type, resolver) => ({ type, description: 'Returns only for current user. Returns `null` otherwise.', resolve(user, arg, context, info) { - if (user.id !== context.user.id) return null; + if (!context.user || user.id !== context.user.id) return null; return resolver ? resolver(user, arg, context, info) : user[info.fieldName]; }, @@ -129,6 +48,11 @@ const User = new GraphQLObjectType({ email: currentUserOnlyField(GraphQLString), name: { type: GraphQLString }, avatarUrl: avatarResolver(), + avatarData: { type: GraphQLString }, + + // TODO: also enable these two fields for requests from the same app? + appId: currentUserOnlyField(GraphQLString), + appUserId: currentUserOnlyField(GraphQLString), facebookId: currentUserOnlyField(GraphQLString), githubId: currentUserOnlyField(GraphQLString), @@ -194,24 +118,32 @@ const User = new GraphQLObjectType({ }, createdAt: { type: GraphQLString }, updatedAt: { type: GraphQLString }, + lastActiveAt: { type: GraphQLString }, }), }); export default User; -export const userFieldResolver = ( +export const userFieldResolver = async ( { userId, appId }, args, { loaders, ...context } ) => { - // If the root document is created by website users, we can resolve user from userId. + // If the root document is created by website users or if the userId is already converted to db userId, + // we can resolve user from userId. // - if (appId === 'WEBSITE') - return loaders.docLoader.load({ index: 'users', id: userId }); + if (userId && appId) { + const id = getUserId({ appId, userId }); + const user = await loaders.docLoader.load({ index: 'users', id }); + if (user) return user; + } + + /* TODO: some unit tests are depending on this code block, need to clean up those tests and then + remove the following lines, and the corresponding unit test. */ // If the user comes from the same client as the root document, return the user id. // - if (context.appId === appId) return { id: userId }; + if (context.appId && context.appId === appId) return { id: userId }; // If not, this client is not allowed to see user. // diff --git a/src/graphql/models/__fixtures__/User.js b/src/graphql/models/__fixtures__/User.js new file mode 100644 index 00000000..b5975682 --- /dev/null +++ b/src/graphql/models/__fixtures__/User.js @@ -0,0 +1,12 @@ +export default { + '/users/doc/6LOqD_z5A8BwUr4gh1P2gw_2zFU3IIrSchTSl-vemod7CChMU': { + name: 'test user 1', + appUserId: 'userTest1', + appId: 'TEST_BACKEND', + }, + + '/users/doc/userTest2': { + name: 'test user 2', + appId: 'WEBSITE', + }, +}; diff --git a/src/graphql/models/__tests__/User.js b/src/graphql/models/__tests__/User.js index 35c94e18..ae9a2b5c 100644 --- a/src/graphql/models/__tests__/User.js +++ b/src/graphql/models/__tests__/User.js @@ -1,72 +1,114 @@ -import { random, sample } from 'lodash'; -import { generatePseudonym, generateOpenPeepsAvatar } from '../User'; - -jest.mock('lodash', () => ({ - random: jest.fn(), - sample: jest.fn(), -})); +import fixtures from '../__fixtures__/User'; +import { userFieldResolver, currentUserOnlyField } from '../User'; +import { loadFixtures, unloadFixtures } from 'util/fixtures'; +import DataLoaders from '../../dataLoaders'; describe('User model', () => { - describe('pseudo name and avatar generators', () => { - it('should generate pseudo names for user', () => { - [ - 0, // adjectives - 1, // names - 2, // towns - 3, // separators - 4, // decorators - 44, // adjectives - 890, // names - 349, // towns - 17, // separators - 42, // decorators - ].forEach(index => sample.mockImplementationOnce(ary => ary[index])); + beforeAll(() => loadFixtures(fixtures)); + afterAll(() => unloadFixtures(fixtures)); + + describe('currentUserOnlyField', () => { + const user = { + id: 'testuser1', + name: 'test user 1', + appUserId: 'userTest1', + appId: 'TEST_BACKEND', + }; + const mockResolver = jest.fn().mockReturnValue('John Doe'); + + it('handles null current user gracefully', async () => { + const args = [user, 'arg', {}, { fieldName: 'name' }]; + expect( + await currentUserOnlyField('type', mockResolver).resolve(...args) + ).toBe(null); + expect(mockResolver).not.toHaveBeenCalled(); + + expect(await currentUserOnlyField('type').resolve(...args)).toBe(null); + }); - expect(generatePseudonym()).toBe(`忠懇的信義區艾達`); - expect(generatePseudonym()).toBe('㊣來自金城✖一本正經的✖金城武㊣'); + it('returns requested field for current user', async () => { + const args = [user, 'arg', { user }, { fieldName: 'name' }]; + expect( + await currentUserOnlyField('type', mockResolver).resolve(...args) + ).toBe('John Doe'); + expect(mockResolver).toHaveBeenLastCalledWith(...args); + mockResolver.mockClear(); + expect(await currentUserOnlyField('type').resolve(...args)).toBe( + user.name + ); }); - it('should generate avatars for user', () => { - [ - 5, // face - 12, // hair - 7, // body - 3, // accessory - 2, // facialHair - 9, // face - 0, // hair - 2, // body - ].forEach(index => sample.mockImplementationOnce(ary => ary[index])); - [ - 0, // with accessory or not - 0, // with facialHair or not - 1, // to flip image or not - 0.23, // backgroundColorIndex - 1, // with accessory or not - 1, // with facialHair or not - 0, // to flip image or not - 0.17, // backgroundColorIndex - ].forEach(r => random.mockReturnValueOnce(r)); + it('does not return requested field for another user', async () => { + const args = [ + user, + 'arg', + { user: { id: 'foo' } }, + { fieldName: 'name' }, + ]; + expect( + await currentUserOnlyField('type', mockResolver).resolve(...args) + ).toBe(null); + expect(mockResolver).not.toHaveBeenCalled(); + + expect(await currentUserOnlyField('type').resolve(...args)).toBe(null); + }); + }); + + describe('userFieldResolver', () => { + const loaders = new DataLoaders(); + const resolveUser = ({ userId, appId }, ctx = {}) => + userFieldResolver({ userId, appId }, {}, { loaders, ...ctx }); + + it('handles missing arguments gracefully', async () => { + expect(await resolveUser({ appId: 'WEBSITE' })).toBe(null); + expect(await resolveUser({ appId: 'TEST_BACKEND' })).toBe(null); + expect(await resolveUser({ userId: 'abc' })).toBe(null); + }); + + it('returns the right backend user given appUserId', async () => { + expect( + await resolveUser({ + appId: 'TEST_BACKEND', + userId: 'userTest1', + }) + ).toMatchSnapshot(); + }); + + it('returns the right backend user given db userId', async () => { + expect( + await resolveUser({ + userId: '6LOqD_z5A8BwUr4gh1P2gw_2zFU3IIrSchTSl-vemod7CChMU', + appId: 'TEST_BACKEND', + }) + ).toMatchSnapshot(); + }); + + it('returns the right web user given userId', async () => { + expect( + await resolveUser({ + userId: 'userTest2', + appId: 'WEBSITE', + }) + ).toMatchSnapshot(); + }); - expect(generateOpenPeepsAvatar()).toMatchObject({ - accessory: 'None', - body: 'PointingUp', - face: 'Contempt', - hair: 'HatHip', - facialHair: 'None', - backgroundColorIndex: 0.23, - flip: true, - }); + it('returns appUserId only to requests from the same client', async () => { + expect( + await resolveUser({ + userId: 'userTest3', + appId: 'WEBSITE', + }) + ).toBe(null); - expect(generateOpenPeepsAvatar()).toMatchObject({ - accessory: 'SunglassWayfarer', - body: 'ButtonShirt', - face: 'EyesClosed', - hair: 'Afro', - facialHair: 'FullMajestic', - backgroundColorIndex: 0.17, - flip: false, - }); + expect( + await resolveUser( + { + userId: 'userTest3', + appId: 'TEST_BACKEND', + }, + { appId: 'TEST_BACKEND' } + ) + ).toStrictEqual({ id: 'userTest3' }); }); }); }); diff --git a/src/graphql/models/__tests__/__snapshots__/User.js.snap b/src/graphql/models/__tests__/__snapshots__/User.js.snap new file mode 100644 index 00000000..a382da2b --- /dev/null +++ b/src/graphql/models/__tests__/__snapshots__/User.js.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`User model userFieldResolver returns the right backend user given appUserId 1`] = ` +Object { + "_cursor": undefined, + "_score": undefined, + "appId": "TEST_BACKEND", + "appUserId": "userTest1", + "highlight": undefined, + "id": "6LOqD_z5A8BwUr4gh1P2gw_2zFU3IIrSchTSl-vemod7CChMU", + "inner_hits": undefined, + "name": "test user 1", +} +`; + +exports[`User model userFieldResolver returns the right backend user given db userId 1`] = ` +Object { + "_cursor": undefined, + "_score": undefined, + "appId": "TEST_BACKEND", + "appUserId": "userTest1", + "highlight": undefined, + "id": "6LOqD_z5A8BwUr4gh1P2gw_2zFU3IIrSchTSl-vemod7CChMU", + "inner_hits": undefined, + "name": "test user 1", +} +`; + +exports[`User model userFieldResolver returns the right web user given userId 1`] = ` +Object { + "_cursor": undefined, + "_score": undefined, + "appId": "WEBSITE", + "highlight": undefined, + "id": "userTest2", + "inner_hits": undefined, + "name": "test user 2", +} +`; diff --git a/src/graphql/mutations/CreateArticle.js b/src/graphql/mutations/CreateArticle.js index b29d14e7..ffff9e44 100644 --- a/src/graphql/mutations/CreateArticle.js +++ b/src/graphql/mutations/CreateArticle.js @@ -1,7 +1,7 @@ import { GraphQLString, GraphQLNonNull } from 'graphql'; import { h64 } from 'xxhashjs'; -import { assertUser } from 'graphql/util'; +import { assertUser } from 'util/user'; import client from 'util/client'; import scrapUrls from 'util/scrapUrls'; diff --git a/src/graphql/mutations/CreateArticleCategory.js b/src/graphql/mutations/CreateArticleCategory.js index 87c3aef1..2e77c942 100644 --- a/src/graphql/mutations/CreateArticleCategory.js +++ b/src/graphql/mutations/CreateArticleCategory.js @@ -6,7 +6,7 @@ import { } from 'graphql'; import client from 'util/client'; -import { assertUser } from 'graphql/util'; +import { assertUser } from 'util/user'; import ArticleCategory from 'graphql/models/ArticleCategory'; /** diff --git a/src/graphql/mutations/CreateArticleReply.js b/src/graphql/mutations/CreateArticleReply.js index f4ebfdc5..07dbd063 100644 --- a/src/graphql/mutations/CreateArticleReply.js +++ b/src/graphql/mutations/CreateArticleReply.js @@ -1,7 +1,7 @@ import { GraphQLString, GraphQLNonNull, GraphQLList } from 'graphql'; import client from 'util/client'; -import { assertUser } from 'graphql/util'; +import { assertUser } from 'util/user'; import ArticleReply from 'graphql/models/ArticleReply'; /** diff --git a/src/graphql/mutations/CreateCategory.js b/src/graphql/mutations/CreateCategory.js index a47aad40..bb0d77a7 100644 --- a/src/graphql/mutations/CreateCategory.js +++ b/src/graphql/mutations/CreateCategory.js @@ -1,6 +1,6 @@ import { GraphQLString, GraphQLNonNull } from 'graphql'; -import { assertUser } from 'graphql/util'; +import { assertUser } from 'util/user'; import client from 'util/client'; diff --git a/src/graphql/mutations/CreateOrUpdateArticleCategoryFeedback.js b/src/graphql/mutations/CreateOrUpdateArticleCategoryFeedback.js index 835f2310..e872e01a 100644 --- a/src/graphql/mutations/CreateOrUpdateArticleCategoryFeedback.js +++ b/src/graphql/mutations/CreateOrUpdateArticleCategoryFeedback.js @@ -1,6 +1,6 @@ import { GraphQLString, GraphQLNonNull } from 'graphql'; -import { assertUser } from 'graphql/util'; +import { assertUser } from 'util/user'; import FeedbackVote from 'graphql/models/FeedbackVote'; import ArticleCategory from 'graphql/models/ArticleCategory'; diff --git a/src/graphql/mutations/CreateOrUpdateArticleReplyFeedback.js b/src/graphql/mutations/CreateOrUpdateArticleReplyFeedback.js index e2084320..f409032c 100644 --- a/src/graphql/mutations/CreateOrUpdateArticleReplyFeedback.js +++ b/src/graphql/mutations/CreateOrUpdateArticleReplyFeedback.js @@ -1,6 +1,6 @@ import { GraphQLString, GraphQLNonNull } from 'graphql'; -import { assertUser } from 'graphql/util'; +import { assertUser } from 'util/user'; import FeedbackVote from 'graphql/models/FeedbackVote'; import ArticleReply from 'graphql/models/ArticleReply'; diff --git a/src/graphql/mutations/CreateOrUpdateReplyRequest.js b/src/graphql/mutations/CreateOrUpdateReplyRequest.js index 5a2b9afe..de9338b0 100644 --- a/src/graphql/mutations/CreateOrUpdateReplyRequest.js +++ b/src/graphql/mutations/CreateOrUpdateReplyRequest.js @@ -1,5 +1,5 @@ import { GraphQLString, GraphQLNonNull } from 'graphql'; -import { assertUser } from 'graphql/util'; +import { assertUser } from 'util/user'; import Article from '../models/Article'; import client, { processMeta } from 'util/client'; diff --git a/src/graphql/mutations/CreateOrUpdateReplyRequestFeedback.js b/src/graphql/mutations/CreateOrUpdateReplyRequestFeedback.js index b32162fb..1f0da682 100644 --- a/src/graphql/mutations/CreateOrUpdateReplyRequestFeedback.js +++ b/src/graphql/mutations/CreateOrUpdateReplyRequestFeedback.js @@ -1,6 +1,6 @@ import { GraphQLString, GraphQLNonNull } from 'graphql'; -import { assertUser } from 'graphql/util'; +import { assertUser } from 'util/user'; import FeedbackVote from 'graphql/models/FeedbackVote'; import ReplyRequest from 'graphql/models/ReplyRequest'; diff --git a/src/graphql/mutations/CreateReply.js b/src/graphql/mutations/CreateReply.js index 3072c022..7155b2d9 100644 --- a/src/graphql/mutations/CreateReply.js +++ b/src/graphql/mutations/CreateReply.js @@ -1,6 +1,6 @@ import { GraphQLString, GraphQLNonNull, GraphQLBoolean } from 'graphql'; -import { assertUser } from 'graphql/util'; +import { assertUser } from 'util/user'; import client from 'util/client'; import scrapUrls from 'util/scrapUrls'; diff --git a/src/graphql/mutations/__tests__/CreateReply.js b/src/graphql/mutations/__tests__/CreateReply.js index ddd2b7dd..a299ae52 100644 --- a/src/graphql/mutations/__tests__/CreateReply.js +++ b/src/graphql/mutations/__tests__/CreateReply.js @@ -135,7 +135,11 @@ describe('CreateReply', () => { type: 'doc', id: replyId, }); + expect(reply._source).toMatchSnapshot(); + + // Cleanup + await client.delete({ index: 'replies', type: 'doc', id: replyId }); }); it('should throw error since a reference is required for type !== NOT_ARTICLE', async () => { diff --git a/src/graphql/queries/__tests__/ListReplies.js b/src/graphql/queries/__tests__/ListReplies.js index de570d25..32ed953c 100644 --- a/src/graphql/queries/__tests__/ListReplies.js +++ b/src/graphql/queries/__tests__/ListReplies.js @@ -1,5 +1,5 @@ -import gql from 'util/GraphQL'; import { loadFixtures, unloadFixtures } from 'util/fixtures'; +import gql from 'util/GraphQL'; import { getCursor } from 'graphql/util'; import fixtures from '../__fixtures__/ListReplies'; diff --git a/src/graphql/util.js b/src/graphql/util.js index 06be9fa5..77805b36 100644 --- a/src/graphql/util.js +++ b/src/graphql/util.js @@ -418,20 +418,6 @@ export function createConnectionType( }); } -export const AUTH_ERROR_MSG = 'userId is not set via query string.'; - -export function assertUser({ userId, appId }) { - if (!userId) { - throw new Error(AUTH_ERROR_MSG); - } - - if (userId && !appId) { - throw new Error( - 'userId is set, but x-app-id or x-app-secret is not set accordingly.' - ); - } -} - export function filterArticleRepliesByStatus(articleReplies, status) { if (!status) return articleReplies; diff --git a/src/index.js b/src/index.js index 7e0d4899..6dc9a72d 100644 --- a/src/index.js +++ b/src/index.js @@ -13,11 +13,10 @@ import { formatError } from 'graphql'; import checkHeaders from './checkHeaders'; import schema from './graphql/schema'; import DataLoaders from './graphql/dataLoaders'; -import { AUTH_ERROR_MSG } from './graphql/util'; import CookieStore from './CookieStore'; - import { loginRouter, authRouter } from './auth'; import rollbar from './rollbarInstance'; +import { AUTH_ERROR_MSG, createOrUpdateUser } from './util/user'; const app = new Koa(); const router = Router(); @@ -78,22 +77,37 @@ router.get('/', ctx => { app.use(koaStatic(path.join(__dirname, '../static/'))); app.use(router.routes(), router.allowedMethods()); -const apolloServer = new ApolloServer({ +export const apolloServer = new ApolloServer({ schema, introspection: true, // Allow introspection in production as well playground: true, - context: ({ ctx }) => ({ - loaders: new DataLoaders(), // new loaders per request - user: ctx.state.user, - - // userId-appId pair - // - userId: - ctx.appId === 'WEBSITE' || ctx.appId === 'DEVELOPMENT_FRONTEND' - ? (ctx.state.user || {}).id - : ctx.query.userId, - appId: ctx.appId, - }), + context: async ({ ctx }) => { + const { + appId, + query: { userId: queryUserId } = {}, + state: { user: { userId: sessionUserId } = {} } = {}, + } = ctx; + + const userId = queryUserId ?? sessionUserId; + + let currentUser = null; + if (appId && userId) { + ({ user: currentUser } = await createOrUpdateUser({ + userId, + appId, + })); + } + + return { + loaders: new DataLoaders(), // new loaders per request + user: currentUser, + + // userId-appId pair + // + userId, + appId, + }; + }, formatError(err) { // make web clients know they should login // diff --git a/src/scripts/__tests__/fetchStatsFromGA.js b/src/scripts/__tests__/fetchStatsFromGA.js index 032eb237..11170f8a 100644 --- a/src/scripts/__tests__/fetchStatsFromGA.js +++ b/src/scripts/__tests__/fetchStatsFromGA.js @@ -61,7 +61,7 @@ describe('fetchStatsFromGA', () => { yargs.argvMock.mockReset(); }); - it('without any arugments', async () => { + it('without any arguments', async () => { yargs.argvMock.mockReturnValue({ useContentGroup: true, loadScript: false, @@ -73,7 +73,7 @@ describe('fetchStatsFromGA', () => { expect(storeScriptInDBMock).not.toHaveBeenCalled(); }); - it('with date arugments', async () => { + it('with date arguments', async () => { yargs.argvMock.mockReturnValue({ startDate: '2020-01-01', endDate: '2020-02-01', @@ -93,7 +93,7 @@ describe('fetchStatsFromGA', () => { expect(storeScriptInDBMock).not.toHaveBeenCalled(); }); - it('with loadScript arugments', async () => { + it('with loadScript arguments', async () => { yargs.argvMock.mockReturnValue({ loadScript: true, useContentGroup: true, @@ -105,7 +105,7 @@ describe('fetchStatsFromGA', () => { expect(storeScriptInDBMock).toHaveBeenCalled(); }); - it('with loadScript and date arugments', async () => { + it('with loadScript and date arguments', async () => { yargs.argvMock.mockReturnValue({ loadScript: true, startDate: '2020-01-01', @@ -363,7 +363,6 @@ describe('fetchStatsFromGA', () => { afterEach(() => { upsertDocStatsMock.mockReset(); }); - it('should call bulkUpdates with right params', async () => { await fetchStatsFromGA.processReport( 'WEB', @@ -475,8 +474,8 @@ describe('fetchStatsFromGA', () => { upsertDocStatsSpy.mockClear(); }); - afterAll(async () => - await client.delete_script({ id: fetchStatsFromGA.upsertScriptID })); + afterAll(() => + client.delete_script({ id: fetchStatsFromGA.upsertScriptID })); it('should aggregate rows of data', async () => { await fetchStatsFromGA.processReport( diff --git a/src/scripts/migrations/__tests__/createBackendUsers.js b/src/scripts/migrations/__tests__/createBackendUsers.js index 5700c5d3..515a6a03 100644 --- a/src/scripts/migrations/__tests__/createBackendUsers.js +++ b/src/scripts/migrations/__tests__/createBackendUsers.js @@ -1,11 +1,9 @@ -import { loadFixtures, clearIndices, saveStateForIndices } from 'util/fixtures'; +import { loadFixtures } from 'util/fixtures'; import client from 'util/client'; import CreateBackendUsers from '../createBackendUsers'; import fixtures from '../__fixtures__/createBackendUsers'; import { sortBy } from 'lodash'; -jest.setTimeout(45000); - const checkAllDocsForIndex = async index => { let res = {}; const { @@ -41,12 +39,8 @@ const indices = [ 'analytics', ]; -let dbStates = {}; describe('createBackendUsers', () => { beforeAll(async () => { - // storing the current db states to restore to after the test is completed - dbStates = await saveStateForIndices(indices); - await clearIndices(indices); await loadFixtures(fixtures.fixturesToLoad); await new CreateBackendUsers({ @@ -62,9 +56,17 @@ describe('createBackendUsers', () => { }); afterAll(async () => { - await clearIndices(indices); - // restore db states to prevent affecting other tests - await loadFixtures(dbStates); + for (const index of indices) { + await client.deleteByQuery({ + index, + body: { + query: { + match_all: {}, + }, + }, + refresh: 'true', + }); + } }); for (const index of indices) { diff --git a/src/scripts/migrations/createBackendUsers.js b/src/scripts/migrations/createBackendUsers.js index 3b08afef..c15209e3 100644 --- a/src/scripts/migrations/createBackendUsers.js +++ b/src/scripts/migrations/createBackendUsers.js @@ -41,7 +41,8 @@ import { generateOpenPeepsAvatar, AvatarTypes, convertAppUserIdToUserId, -} from 'graphql/models/User'; + isBackendApp, +} from 'util/user'; import { get, set } from 'lodash'; const AGG_NAME = 'userIdPair'; @@ -105,9 +106,6 @@ const backendUserQuery = { }, }; -const isBackendApp = appId => - appId !== 'WEBSITE' && appId !== 'DEVELOPMENT_FRONTEND'; - const userReferenceInSchema = { articlecategoryfeedbacks: { fields: [], @@ -201,7 +199,7 @@ export default class CreateBackendUsers { if (error) { logError(error); } else if (isBackendApp(appId)) { - const dbUserId = convertAppUserIdToUserId(appId, userId); + const dbUserId = convertAppUserIdToUserId({ appId, appUserId: userId }); const appUserId = get(this.reversedUserIdMap, [dbUserId, 1]); // if the hashed id already exists, check for collision if (appUserId !== undefined) { diff --git a/src/util/__fixtures__/user.js b/src/util/__fixtures__/user.js new file mode 100644 index 00000000..a951e895 --- /dev/null +++ b/src/util/__fixtures__/user.js @@ -0,0 +1,7 @@ +export default { + '/users/doc/6LOqD_QabTT7XXTrsz7ybEa5PLfc0GfXlV578HYPhODPfSWc8': { + name: 'test user 1', + appUserId: 'testUser1', + appId: 'TEST_BACKEND', + }, +}; diff --git a/src/util/__tests__/__snapshots__/user.js.snap b/src/util/__tests__/__snapshots__/user.js.snap new file mode 100644 index 00000000..ed0c0a98 --- /dev/null +++ b/src/util/__tests__/__snapshots__/user.js.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`user utils CreateOrUpdateUser creates backend user if not existed 1`] = ` +Object { + "_cursor": undefined, + "_score": undefined, + "appId": "TEST_BACKEND", + "appUserId": "testUser2", + "avatarData": "{\\"accessory\\":\\"mask\\"}", + "avatarType": "OpenPeeps", + "createdAt": "2020-10-10T00:00:00.000Z", + "highlight": undefined, + "id": "6LOqD_3gpe4ZVaxRvemf7KNTfm6y3WNBu1hbs-5MRdSWiWVss", + "inner_hits": undefined, + "lastActiveAt": "2020-10-10T00:00:00.000Z", + "name": "Friendly Neighborhood Spider Man", + "updatedAt": "2020-10-10T00:00:00.000Z", +} +`; + +exports[`user utils CreateOrUpdateUser creates backend user if not existed 2`] = ` +Object { + "appId": "TEST_BACKEND", + "appUserId": "testUser2", + "avatarData": "{\\"accessory\\":\\"mask\\"}", + "avatarType": "OpenPeeps", + "createdAt": "2020-10-10T00:00:00.000Z", + "lastActiveAt": "2020-10-10T00:00:00.000Z", + "name": "Friendly Neighborhood Spider Man", + "updatedAt": "2020-10-10T00:00:00.000Z", +} +`; + +exports[`user utils CreateOrUpdateUser logs error if collision occurs 1`] = ` +Object { + "_cursor": undefined, + "_score": undefined, + "appId": "TEST_BACKEND", + "appUserId": "testUser1", + "highlight": undefined, + "id": "6LOqD_QabTT7XXTrsz7ybEa5PLfc0GfXlV578HYPhODPfSWc8", + "inner_hits": undefined, + "lastActiveAt": "2020-10-10T01:00:00.000Z", + "name": "test user 1", +} +`; + +exports[`user utils CreateOrUpdateUser logs error if collision occurs 2`] = ` +Object { + "appId": "TEST_BACKEND", + "appUserId": "testUser1", + "lastActiveAt": "2020-10-10T01:00:00.000Z", + "name": "test user 1", +} +`; + +exports[`user utils CreateOrUpdateUser logs error if collision occurs 3`] = ` +Array [ + Array [ + "createBackendUserError: collision found! testUser1 and testUser3 both hash to 6LOqD_QabTT7XXTrsz7ybEa5PLfc0GfXlV578HYPhODPfSWc8", + ], +] +`; + +exports[`user utils CreateOrUpdateUser updates backend users' last active time if user already existed 1`] = ` +Object { + "_cursor": undefined, + "_score": undefined, + "appId": "TEST_BACKEND", + "appUserId": "testUser1", + "highlight": undefined, + "id": "6LOqD_QabTT7XXTrsz7ybEa5PLfc0GfXlV578HYPhODPfSWc8", + "inner_hits": undefined, + "lastActiveAt": "2020-10-10T01:00:00.000Z", + "name": "test user 1", +} +`; + +exports[`user utils CreateOrUpdateUser updates backend users' last active time if user already existed 2`] = ` +Object { + "appId": "TEST_BACKEND", + "appUserId": "testUser1", + "lastActiveAt": "2020-10-10T01:00:00.000Z", + "name": "test user 1", +} +`; diff --git a/src/util/__tests__/user.js b/src/util/__tests__/user.js new file mode 100644 index 00000000..70b58e90 --- /dev/null +++ b/src/util/__tests__/user.js @@ -0,0 +1,213 @@ +import { loadFixtures, unloadFixtures } from 'util/fixtures'; +import client from 'util/client'; +import MockDate from 'mockdate'; +import fixtures from '../__fixtures__/user'; +import rollbar from 'rollbarInstance'; +import { random, sample } from 'lodash'; + +import { + generatePseudonym, + generateOpenPeepsAvatar, + getUserId, + createOrUpdateUser, + assertUser, + AUTH_ERROR_MSG, +} from '../user'; + +jest.mock('lodash', () => { + const actualLodash = jest.requireActual('lodash'); + return { + random: jest.spyOn(actualLodash, 'random'), + sample: jest.spyOn(actualLodash, 'sample'), + }; +}); + +jest.mock('../user', () => { + const userUtils = jest.requireActual('../user'); + + return { + ...userUtils, + generatePseudonym: jest.spyOn(userUtils, 'generatePseudonym'), + generateOpenPeepsAvatar: jest.spyOn(userUtils, 'generateOpenPeepsAvatar'), + getUserId: jest.spyOn(userUtils, 'getUserId'), + }; +}); + +describe('user utils', () => { + describe('assertUser', () => { + it('should throw error if userId is not present', () => { + expect(() => assertUser({})).toThrow(AUTH_ERROR_MSG); + expect(() => assertUser({ appId: 'appId' })).toThrow(AUTH_ERROR_MSG); + }); + + it('should throw error if appId is not present', () => { + expect(() => assertUser({ userId: 'userId' })).toThrow( + 'userId is set, but x-app-id or x-app-secret is not set accordingly.' + ); + }); + + it('should not throw error if userId and appId are both present', () => { + expect(() => + assertUser({ appId: 'appId', userId: 'userId' }) + ).not.toThrow(); + }); + }); + + describe('pseudo name and avatar generators', () => { + it('should generate pseudo names for user', () => { + [ + 0, // adjectives + 1, // names + 2, // towns + 3, // separators + 4, // decorators + 44, // adjectives + 890, // names + 349, // towns + 17, // separators + 42, // decorators + ].forEach(index => sample.mockImplementationOnce(ary => ary[index])); + + expect(generatePseudonym()).toBe(`忠懇的信義區艾達`); + expect(generatePseudonym()).toBe('㊣來自金城✖一本正經的✖金城武㊣'); + }); + + it('should generate avatars for user', () => { + [ + 5, // face + 12, // hair + 7, // body + 3, // accessory + 2, // facialHair + 9, // face + 0, // hair + 2, // body + ].forEach(index => sample.mockImplementationOnce(ary => ary[index])); + [ + 0, // with accessory or not + 0, // with facialHair or not + 1, // to flip image or not + 0.23, // backgroundColorIndex + 1, // with accessory or not + 1, // with facialHair or not + 0, // to flip image or not + 0.17, // backgroundColorIndex + ].forEach(r => random.mockReturnValueOnce(r)); + + expect(generateOpenPeepsAvatar()).toMatchObject({ + accessory: 'None', + body: 'PointingUp', + face: 'Contempt', + hair: 'HatHip', + facialHair: 'None', + backgroundColorIndex: 0.23, + flip: true, + }); + + expect(generateOpenPeepsAvatar()).toMatchObject({ + accessory: 'SunglassWayfarer', + body: 'ButtonShirt', + face: 'EyesClosed', + hair: 'Afro', + facialHair: 'FullMajestic', + backgroundColorIndex: 0.17, + flip: false, + }); + }); + }); + + describe('CreateOrUpdateUser', () => { + beforeAll(async () => { + await loadFixtures(fixtures); + generatePseudonym.mockReturnValue('Friendly Neighborhood Spider Man'); + generateOpenPeepsAvatar.mockReturnValue({ accessory: 'mask' }); + }); + + afterAll(() => unloadFixtures(fixtures)); + + it('creates backend user if not existed', async () => { + MockDate.set(1602288000000); + const userId = 'testUser2'; + const appId = 'TEST_BACKEND'; + + const { user, isCreated } = await createOrUpdateUser({ + userId, + appId, + }); + + expect(isCreated).toBe(true); + expect(user).toMatchSnapshot(); + + const id = getUserId({ userId, appId }); + + const { + body: { _source: source }, + } = await client.get({ + index: 'users', + type: 'doc', + id, + }); + expect(source).toMatchSnapshot(); + expect(rollbar.error).not.toHaveBeenCalled(); + rollbar.error.mockClear(); + + MockDate.reset(); + await client.delete({ index: 'users', type: 'doc', id }); + }); + + it("updates backend users' last active time if user already existed", async () => { + MockDate.set(1602291600000); + + const userId = 'testUser1'; + const appId = 'TEST_BACKEND'; + + const { user, isCreated } = await createOrUpdateUser({ + userId, + appId, + }); + + expect(isCreated).toBe(false); + expect(user).toMatchSnapshot(); + + const id = getUserId({ userId, appId }); + const { + body: { _source: source }, + } = await client.get({ + index: 'users', + type: 'doc', + id, + }); + expect(source).toMatchSnapshot(); + expect(rollbar.error).not.toHaveBeenCalled(); + rollbar.error.mockClear(); + }); + + it('logs error if collision occurs', async () => { + MockDate.set(1602291600000); + + const userId = 'testUser3'; + const appId = 'TEST_BACKEND'; + const id = getUserId({ userId: 'testUser1', appId }); + + getUserId.mockReturnValueOnce(id); + const { user, isCreated } = await createOrUpdateUser({ + userId, + appId, + }); + + expect(isCreated).toBe(false); + expect(user).toMatchSnapshot(); + + const { + body: { _source: source }, + } = await client.get({ + index: 'users', + type: 'doc', + id, + }); + expect(source).toMatchSnapshot(); + expect(rollbar.error.mock.calls).toMatchSnapshot(); + rollbar.error.mockClear(); + }); + }); +}); diff --git a/src/util/user.js b/src/util/user.js new file mode 100644 index 00000000..666825c4 --- /dev/null +++ b/src/util/user.js @@ -0,0 +1,182 @@ +import { + adjectives, + names, + towns, + separators, + decorators, +} from 'util/pseudonymDict'; +import { + accessories, + faces, + facialHairStyles, + hairStyles, + bustPoses, +} from 'util/openPeepsOptions'; +import { sample, random } from 'lodash'; +import client, { processMeta } from 'util/client'; +import rollbar from 'rollbarInstance'; +import crypto from 'crypto'; + +export const AUTH_ERROR_MSG = 'userId is not set via query string.'; + +export function assertUser({ userId, appId }) { + if (!userId) { + throw new Error(AUTH_ERROR_MSG); + } + + if (userId && !appId) { + throw new Error( + 'userId is set, but x-app-id or x-app-secret is not set accordingly.' + ); + } +} + +/** + * Generates a pseudonym. + */ +export const generatePseudonym = () => { + const [adj, name, place, separator, decorator] = [ + adjectives, + names, + towns, + separators, + decorators, + ].map(ary => sample(ary)); + return decorator(separator({ adj, name, place })); +}; + +export const AvatarTypes = { + OpenPeeps: 'OpenPeeps', +}; + +export const isBackendApp = appId => + appId !== 'WEBSITE' && appId !== 'DEVELOPMENT_FRONTEND'; + +// 6 for appId prefix and 43 for 256bit hashed userId with base64 encoding. +const BACKEND_USER_ID_LEN = 6 + 43; + +/** + * Generates data for open peeps avatar. + */ +export const generateOpenPeepsAvatar = () => { + const accessory = random() ? sample(accessories) : 'None'; + const facialHair = random() ? sample(facialHairStyles) : 'None'; + const flip = !!random(); + const backgroundColorIndex = random(0, 1, true); + + const face = sample(faces); + const hair = sample(hairStyles); + const body = sample(bustPoses); + + return { + accessory, + body, + face, + hair, + facialHair, + backgroundColorIndex, + flip, + }; +}; + +/** + * Given appId, userId pair, where userId could be appUserId or dbUserID, returns the id of corresponding user in db. + */ +export const getUserId = ({ appId, userId }) => { + if (!appId || !isBackendApp(appId) || isDBUserId({ appId, userId })) + return userId; + else return convertAppUserIdToUserId({ appId, appUserId: userId }); +}; + +/** + * Check if the userId for a backend user is the user id in db or it is the app user Id. + */ +export const isDBUserId = ({ appId, userId }) => + appId && + userId && + userId.length === BACKEND_USER_ID_LEN && + userId.substr(0, 6) === `${encodeAppId(appId)}_`; + +export const encodeAppId = appId => + crypto + .createHash('md5') + .update(appId) + .digest('base64') + .replace(/[+/]/g, '') + .substr(0, 5); + +export const sha256 = value => + crypto + .createHash('sha256') + .update(value) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + +/** + * @param {string} appUserId - user ID given by an backend app + * @param {string} appId - app ID + * @returns {string} the id used to index `user` in db + */ +export const convertAppUserIdToUserId = ({ appId, appUserId }) => { + return `${encodeAppId(appId)}_${sha256(appUserId)}`; +}; + +/** + * Index backend user if not existed, and record the last active time as now. + * + * @param {string} userID - either appUserID given by an backend app or userId for frontend users + * @param {string} appId - app ID + * + * @returns {user: User, isCreated: boolean} + */ +export async function createOrUpdateUser({ userId, appId }) { + assertUser({ appId, userId }); + const now = new Date().toISOString(); + const dbUserId = exports.getUserId({ appId, userId }); + const { + body: { result, get: userFound }, + } = await client.update({ + index: 'users', + type: 'doc', + id: dbUserId, + body: { + doc: { + lastActiveAt: now, + }, + upsert: { + name: exports.generatePseudonym(), + avatarType: AvatarTypes.OpenPeeps, + avatarData: JSON.stringify(exports.generateOpenPeepsAvatar()), + appId, + appUserId: userId, + createdAt: now, + updatedAt: now, + lastActiveAt: now, + }, + _source: true, + }, + }); + + const isCreated = result === 'created'; + const user = processMeta({ ...userFound, _id: dbUserId }); + + // checking for collision + if ( + !isCreated && + isBackendApp(appId) && + (user.appId !== appId || user.appUserId !== userId) + ) { + const errorMessage = `collision found! ${ + user.appUserId + } and ${userId} both hash to ${dbUserId}`; + console.log(errorMessage); + rollbar.error(`createBackendUserError: ${errorMessage}`); + } + + return { + user, + isCreated, + }; +} diff --git a/test/postTest.js b/test/postTest.js new file mode 100644 index 00000000..cb200edf --- /dev/null +++ b/test/postTest.js @@ -0,0 +1,32 @@ +import main from 'scripts/cleanupUrls'; +import client from 'util/client'; + + +const checkDocs = async () => { + + const {body: aliases} = await client.cat.aliases({ + format: 'JSON' + }) + + const indices = aliases.map(i => i.alias); + + const { body: { hits: { total, hits } } } = await client.search({ + index: indices, + _source: 'false' + }) + + if (total > 0) { + console.log('\x1b[33m'); + console.log('WARNING: test db is not cleaned up properly.'); + const docs = hits.map(d => `/${d._index}/${d._type}/${d._id}`) + console.log(JSON.stringify(docs, null, 2)); + console.log('\x1b[0m'); + + for (const d of hits) { + await client.delete({index: d._index, type: d._type, id: d._id}) + } + process.exit(1) + } +} + +checkDocs() diff --git a/test/setup.js b/test/setup.js index 50b8080a..bd699c0b 100644 --- a/test/setup.js +++ b/test/setup.js @@ -1,6 +1,15 @@ import 'dotenv/config'; + jest.mock(__dirname + '../../src/util/grpc'); +jest.mock(__dirname + '../../src/rollbarInstance', () => ({ + __esModule: true, + default: { error: jest.fn() }, +})); + +jest.setTimeout(process.env.JEST_TIMEOUT || 5000); + + expect.extend({ toBeNaN(received) { const pass = isNaN(received); diff --git a/test/testSequencer.js b/test/testSequencer.js new file mode 100644 index 00000000..ef75eea1 --- /dev/null +++ b/test/testSequencer.js @@ -0,0 +1,13 @@ +const Sequencer = require('@jest/test-sequencer').default; + +class TestSequencer extends Sequencer { + sort(tests) { + // Test structure information + // https://github.com/facebook/jest/blob/6b8b1404a1d9254e7d5d90a8934087a9c9899dab/packages/jest-runner/src/types.ts#L17-L21 + const copyTests = Array.from(tests); + return copyTests.sort((testA, testB) => (testA.path > testB.path ? 1 : -1)); + } +} + + +module.exports = TestSequencer; \ No newline at end of file diff --git a/test/util/fixtures.js b/test/util/fixtures.js index 6f2a9832..9c695dd6 100644 --- a/test/util/fixtures.js +++ b/test/util/fixtures.js @@ -60,36 +60,3 @@ export async function resetFrom(fixtureMap, key) { refresh: 'true', }); } - -export async function saveStateForIndices(indices) { - let states = {} - for (const index of indices) { - const { body: { hits: { hits } } } = await client.search({ - index, - body: { - query: { - match_all: {}, - }, - }, - size: 10000 - }) - for (const doc of hits) { - states[`/${doc._index}/${doc._type}/${doc._id}`] = { ...doc._source }; - } - } - return states -} - -export async function clearIndices(indices) { - for (const index of indices) { - await client.deleteByQuery({ - index, - body: { - query: { - match_all: {}, - }, - }, - refresh: 'true' - }) - } -} \ No newline at end of file