diff --git a/packages/core/package.json b/packages/core/package.json index 7efd7b765..61e790eb8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -59,6 +59,7 @@ "@types/lodash.pick": "^4.4.6", "@types/node": "^16.3.1", "@types/oidc-provider": "^7.8.0", + "@types/supertest": "^2.0.11", "codecov": "^3.8.3", "eslint": "^8.1.0", "jest": "^27.0.6", @@ -67,6 +68,7 @@ "nock": "^13.2.2", "openapi-types": "^9.1.0", "prettier": "^2.3.2", + "supertest": "^6.2.2", "ts-jest": "^27.0.5", "tsc-watch": "^4.4.0", "typescript": "^4.5.5" diff --git a/packages/core/src/middleware/koa-user-info.test.ts b/packages/core/src/middleware/koa-user-info.test.ts index 791633037..9882aed88 100644 --- a/packages/core/src/middleware/koa-user-info.test.ts +++ b/packages/core/src/middleware/koa-user-info.test.ts @@ -1,29 +1,11 @@ -import { User, userInfoSelectFields } from '@logto/schemas'; -import pick from 'lodash.pick'; - import RequestError from '@/errors/RequestError'; import * as userQueries from '@/queries/user'; -import { createContextWithRouteParameters } from '@/utils/test-utils'; +import { mockUser, mockUserResponse, createContextWithRouteParameters } from '@/utils/test-utils'; import koaUserInfo from './koa-user-info'; const findUserByIdSpy = jest.spyOn(userQueries, 'findUserById'); -const mockUser: User = { - id: 'foo', - username: 'foo', - primaryEmail: 'foo@logto.io', - primaryPhone: '111111', - roleNames: ['admin'], - passwordEncrypted: null, - passwordEncryptionMethod: null, - passwordEncryptionSalt: null, - name: null, - avatar: null, - identities: {}, - customData: {}, -}; - describe('koaUserInfo middleware', () => { const next = jest.fn(); @@ -38,7 +20,7 @@ describe('koaUserInfo middleware', () => { await koaUserInfo()(ctx, next); - expect(ctx.userInfo).toEqual(pick(mockUser, ...userInfoSelectFields)); + expect(ctx.userInfo).toEqual(mockUserResponse); }); it('should throw if is not authenticated', async () => { diff --git a/packages/core/src/routes/admin-user.test.ts b/packages/core/src/routes/admin-user.test.ts new file mode 100644 index 000000000..d68e72135 --- /dev/null +++ b/packages/core/src/routes/admin-user.test.ts @@ -0,0 +1,162 @@ +import { CreateUser, User } from '@logto/schemas'; +import Koa from 'koa'; +import Router from 'koa-router'; +import request from 'supertest'; + +import { hasUser, findUserById } from '@/queries/user'; +import { mockUser, mockUserResponse } from '@/utils/test-utils'; + +import adminUserRoutes from './admin-user'; +import { AuthedRouter } from './types'; + +jest.mock('@/queries/user', () => ({ + findTotalNumberOfUsers: jest.fn(async () => ({ count: 10 })), + findAllUsers: jest.fn(async (): Promise => [mockUser]), + findUserById: jest.fn(async (): Promise => mockUser), + hasUser: jest.fn(async () => false), + updateUserById: jest.fn( + async (_, data: Partial): Promise => ({ + ...mockUser, + ...data, + }) + ), + insertUser: jest.fn( + async (user: CreateUser): Promise => ({ + ...mockUser, + ...user, + }) + ), +})); + +jest.mock('@/lib/user', () => ({ + generateUserId: jest.fn(() => 'fooId'), + encryptUserPassword: jest.fn(() => ({ + passwordEncryptionSalt: 'salt', + passwordEncrypted: 'password', + passwordEncryptionMethod: 'saltAndPepper', + })), +})); + +describe('adminUserRoutes', () => { + const app = new Koa(); + const router: AuthedRouter = new Router(); + + adminUserRoutes(router); + app.use(router.routes()).use(router.allowedMethods()); + + const userRequest = request(app.callback()); + + it('GET /users', async () => { + const response = await userRequest.get('/users'); + expect(response.status).toEqual(200); + expect(response.body).toEqual([mockUserResponse]); + }); + + it('GET /users/:userId', async () => { + const response = await userRequest.get('/users/foo'); + expect(response.status).toEqual(200); + expect(response.body).toEqual(mockUserResponse); + }); + + it('POST /users', async () => { + const username = 'MJ@logto.io'; + const password = 'PASSWORD'; + const name = 'Micheal'; + + const response = await userRequest.post('/users').send({ username, password, name }); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + ...mockUserResponse, + id: 'fooId', + username, + name, + }); + }); + + it('POST /users should throw with invalid input params', async () => { + const username = 'MJ@logto.io'; + const password = 'PASSWORD'; + const name = 'Micheal'; + + // Missing input + await expect(userRequest.post('/users').send({})).resolves.toHaveProperty('status', 400); + await expect(userRequest.post('/users').send({ username, password })).resolves.toHaveProperty( + 'status', + 400 + ); + await expect(userRequest.post('/users').send({ username, name })).resolves.toHaveProperty( + 'status', + 400 + ); + await expect(userRequest.post('/users').send({ password, name })).resolves.toHaveProperty( + 'status', + 400 + ); + + // Invalid input format + await expect( + userRequest.post('/users').send({ username: 'xy', password, name }) + ).resolves.toHaveProperty('status', 400); + await expect( + userRequest.post('/users').send({ username, password: 'abc', name }) + ).resolves.toHaveProperty('status', 400); + await expect( + userRequest.post('/users').send({ username, password, name: 'xy' }) + ).resolves.toHaveProperty('status', 400); + }); + + it('POST /users should throw if username exist', async () => { + const mockHasUser = hasUser as jest.Mock; + mockHasUser.mockImplementationOnce(async () => Promise.resolve(true)); + + const username = 'MJ@logto.io'; + const password = 'PASSWORD'; + const name = 'Micheal'; + + await expect( + userRequest.post('/users').send({ username, password, name }) + ).resolves.toHaveProperty('status', 422); + }); + + it('PATCH /users/:userId', async () => { + const name = 'Micheal'; + const avatar = 'http://www.micheal.png'; + + const response = await userRequest.patch('/users/foo').send({ name, avatar }); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + ...mockUserResponse, + name, + avatar, + }); + }); + + it('PATCH /users/:userId throw with invalid input params', async () => { + const name = 'Micheal'; + const avatar = 'http://www.micheal.png'; + + await expect(userRequest.patch('/users/foo').send({ avatar })).resolves.toHaveProperty( + 'status', + 200 + ); + + await expect( + userRequest.patch('/users/foo').send({ name, avatar: 'non url' }) + ).resolves.toHaveProperty('status', 400); + }); + + it('PATCH /users/:userId throw if user not found', async () => { + const name = 'Micheal'; + const avatar = 'http://www.micheal.png'; + + const mockFindUserById = findUserById as jest.Mock; + mockFindUserById.mockImplementationOnce(() => { + throw new Error(' '); + }); + + await expect(userRequest.patch('/users/foo').send({ name, avatar })).resolves.toHaveProperty( + 'status', + 500 + ); + }); +}); diff --git a/packages/core/src/routes/admin-user.ts b/packages/core/src/routes/admin-user.ts index d30f53e6e..a341f8610 100644 --- a/packages/core/src/routes/admin-user.ts +++ b/packages/core/src/routes/admin-user.ts @@ -39,6 +39,7 @@ export default function adminUserRoutes(router: T) { router.get( '/users/:userId', + // TODO: No need to guard koaGuard({ params: object({ userId: string() }), }), @@ -66,7 +67,6 @@ export default function adminUserRoutes(router: T) { }), async (ctx, next) => { const { username, password, name } = ctx.guard.body; - assertThat( !(await hasUser(username)), new RequestError({ diff --git a/packages/core/src/utils/test-utils.ts b/packages/core/src/utils/test-utils.ts index bcc44855b..c1d99186c 100644 --- a/packages/core/src/utils/test-utils.ts +++ b/packages/core/src/utils/test-utils.ts @@ -1,9 +1,28 @@ +import { User, userInfoSelectFields } from '@logto/schemas'; import { createMockContext, Options } from '@shopify/jest-koa-mocks'; import { MiddlewareType, Context } from 'koa'; import Router, { IRouterParamContext } from 'koa-router'; +import pick from 'lodash.pick'; import { createMockPool, createMockQueryResult, QueryResultRowType } from 'slonik'; import { PrimitiveValueExpressionType } from 'slonik/dist/src/types.d'; +export const mockUser: User = { + id: 'foo', + username: 'foo', + primaryEmail: 'foo@logto.io', + primaryPhone: '111111', + roleNames: ['admin'], + passwordEncrypted: null, + passwordEncryptionMethod: null, + passwordEncryptionSalt: null, + name: null, + avatar: null, + identities: {}, + customData: {}, +}; + +export const mockUserResponse = pick(mockUser, ...userInfoSelectFields); + export const createTestPool = ( expectSql?: string, returning?: T | ((sql: string, values: readonly PrimitiveValueExpressionType[]) => T) @@ -25,18 +44,6 @@ export const createTestPool = ( }, }); -export const envVariablesSetUp = () => { - const OIDC_PROVIDER_PRIVATE_KEY_BASE64 = - 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlDV2dJQkFBS0JnR3pLendQcVp6Q3dncjR5a0U1NTN2aWw3QTZYM2l1VnJ3TVJtbVJDTVNBL3lkUm04bXA1CjlHZUYyMlRCSVBtUEVNM29Lbnk4KytFL2FDRnByWXVDa0loREhodVR5N1diT25nd3kyb3JpYnNEQm1OS3FybTkKM0xkYWYrZm1aU2tsL0FMUjZNeUhNV2dTUkQrbFhxVnplNFdSRGIzVTlrTyt3RmVXUlNZNmlRL2pBZ01CQUFFQwpnWUJOZkczUjVpUTFJNk1iZ0x3VGlPM3N2NURRSEE3YmtETWt4bWJtdmRacmw4TlRDemZoNnBiUEhTSFVNMUlmCkxXelVtMldYanFzQUZiOCsvUnZrWDh3OHI3SENNUUdLVGs0ay9adkZ5YUhkM2tIUXhjSkJPakNOUUtjS2NZalUKRGdnTUVJeW5PblNZNjJpWEV6RExKVTJEMVUrY3JEbTZXUTVHaG1NS1p2Vnl3UUpCQU1lcFBFV2gwakNDOEdmQwpQQU1yT1JvOHJYeHYwVEdXNlJWYmxad0ppdjhNeGZacnpZT1cwZUFPek9IK0ZRWE90SjNTdUZONzdEcVQ5TDI3CmN2M3QySkVDUVFDTGZZeVl2ZUg0UnY2bnVET0RnckkzRUJHMFNJbURHcC94UUV2NEk5Z0hrRFF0aFF4bW5xNTEKZ1QxajhFN1lmRHEwMTkvN2htL3dmMXNzMERQNkpic3pBa0JqOEUzKy9MVGRHMjJDUWpNUDB2N09KemtmWkVqdAo3WC9WOVBXNkdQeStGWUt4aWR4ZzFZbFFBWmlFTms0SGppUFNLN3VmN2hPY2JwcStyYWt0ZVhSQkFrQmhaaFFECkh5c20wbVBFTnNGNWhZdnRHTUpUOFFaYnpmNTZWUnYyc3dpSUYyL25qT3hneDFJbjZFczJlamlEdnhLNjdiV1AKQ29zbEViaFhMVFh0NStTekFrQjJQOUYzNExubE9tVjh4Zjk1VmVlcXNPbDFmWWx2Uy9vUUx1a2ZxVkJsTmtzNgpzdmNLVDJOQjlzSHlCeE8vY3Zqa0ZpWXdHR2MzNjlmQklkcDU1S2IwCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t'; - const UI_SIGN_IN_ROUTE = '/sign-in'; - - process.env = { - ...process.env, - OIDC_PROVIDER_PRIVATE_KEY_BASE64, - UI_SIGN_IN_ROUTE, - }; -}; - export const emptyMiddleware = (): MiddlewareType => // Intend to mock the async middleware diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e2b99497..b8e0b0a93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,7 @@ importers: '@types/lodash.pick': ^4.4.6 '@types/node': ^16.3.1 '@types/oidc-provider': ^7.8.0 + '@types/supertest': ^2.0.11 codecov: ^3.8.3 dayjs: ^1.10.5 decamelize: ^5.0.0 @@ -111,6 +112,7 @@ importers: slonik: ^23.8.3 slonik-interceptor-preset: ^1.2.10 snakecase-keys: ^5.1.0 + supertest: ^6.2.2 ts-jest: ^27.0.5 tsc-watch: ^4.4.0 typescript: ^4.5.5 @@ -156,6 +158,7 @@ importers: '@types/lodash.pick': 4.4.6 '@types/node': 16.11.12 '@types/oidc-provider': 7.8.1 + '@types/supertest': 2.0.11 codecov: 3.8.3 eslint: 8.4.1 jest: 27.4.4 @@ -164,6 +167,7 @@ importers: nock: 13.2.2 openapi-types: 9.3.1 prettier: 2.5.1 + supertest: 6.2.2 ts-jest: 27.1.1_0ef321b3552d50570980838f9f6677eb tsc-watch: 4.5.0_typescript@4.5.5 typescript: 4.5.5 @@ -3074,6 +3078,10 @@ packages: resolution: {integrity: sha512-0mPF08jn9zYI0n0Q/Pnz7C4kThdSt+6LD4amsrYDDpgBfrVWa3TcCOxKX1zkGgYniGagRv8heN2cbh+CAn+uuQ==} dev: true + /@types/cookiejar/2.1.2: + resolution: {integrity: sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==} + dev: true + /@types/cookies/0.7.7: resolution: {integrity: sha512-h7BcvPUogWbKCzBR2lY4oqaZbO3jXZksexYJVFvkrFeLgbZjQkU4x8pRq6eg2MHXQhY0McQdqmmsxRWlVAHooA==} dependencies: @@ -3354,6 +3362,19 @@ packages: resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} dev: true + /@types/superagent/4.1.15: + resolution: {integrity: sha512-mu/N4uvfDN2zVQQ5AYJI/g4qxn2bHB6521t1UuH09ShNWjebTqN0ZFuYK9uYjcgmI0dTQEs+Owi1EO6U0OkOZQ==} + dependencies: + '@types/cookiejar': 2.1.2 + '@types/node': 16.11.12 + dev: true + + /@types/supertest/2.0.11: + resolution: {integrity: sha512-uci4Esokrw9qGb9bvhhSVEjd6rkny/dk5PK/Qz4yxKiyppEI+dOPlNrZBahE3i+PoKFYyDxChVXZ/ysS/nrm1Q==} + dependencies: + '@types/superagent': 4.1.15 + dev: true + /@types/unist/2.0.6: resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==} dev: true @@ -4416,6 +4437,10 @@ packages: dot-prop: 5.3.0 dev: true + /component-emitter/1.3.0: + resolution: {integrity: sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==} + dev: true + /concat-map/0.0.1: resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} @@ -4553,6 +4578,10 @@ packages: safe-buffer: 5.1.2 dev: true + /cookiejar/2.1.3: + resolution: {integrity: sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==} + dev: true + /cookies/0.8.0: resolution: {integrity: sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==} engines: {node: '>= 0.8'} @@ -5787,6 +5816,10 @@ packages: boolean: 3.1.4 dev: false + /fast-safe-stringify/2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + dev: true + /fast-url-parser/1.1.3: resolution: {integrity: sha1-9K8+qfNNiicc9YrSs3WfQx8LMY0=} dependencies: @@ -5907,11 +5940,29 @@ packages: mime-types: 2.1.34 dev: true + /form-data/4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.34 + dev: true + /formidable/1.2.6: resolution: {integrity: sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==} deprecated: 'Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau' dev: false + /formidable/2.0.1: + resolution: {integrity: sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==} + dependencies: + dezalgo: 1.0.3 + hexoid: 1.0.0 + once: 1.4.0 + qs: 6.9.3 + dev: true + /fresh/0.5.2: resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=} engines: {node: '>= 0.6'} @@ -6313,6 +6364,11 @@ packages: dependencies: function-bind: 1.1.1 + /hexoid/1.0.0: + resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} + engines: {node: '>=8'} + dev: true + /history/4.10.1: resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==} dependencies: @@ -8196,7 +8252,7 @@ packages: dependencies: universalify: 2.0.0 optionalDependencies: - graceful-fs: 4.2.8 + graceful-fs: 4.2.9 dev: true /jsonparse/1.3.1: @@ -8889,6 +8945,12 @@ packages: hasBin: true dev: true + /mime/2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + dev: true + /mimic-fn/2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -10735,6 +10797,11 @@ packages: engines: {node: '>=0.6'} dev: true + /qs/6.9.3: + resolution: {integrity: sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==} + engines: {node: '>=0.6'} + dev: true + /query-string/6.14.1: resolution: {integrity: sha512-XDxAeVmpfu1/6IjyT/gXHOl+S0vQ9owggJ30hhWKdHAsNPOcasn5o9BW0eejZqL2e4vMjhAxoW3jVHcD6mbcYw==} engines: {node: '>=6'} @@ -12027,6 +12094,35 @@ packages: postcss: 7.0.39 dev: true + /superagent/7.1.1: + resolution: {integrity: sha512-CQ2weSS6M+doIwwYFoMatklhRbx6sVNdB99OEJ5czcP3cng76Ljqus694knFWgOj3RkrtxZqIgpe6vhe0J7QWQ==} + engines: {node: '>=6.4.0 <13 || >=14'} + dependencies: + component-emitter: 1.3.0 + cookiejar: 2.1.3 + debug: 4.3.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.0 + formidable: 2.0.1 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.10.2 + readable-stream: 3.6.0 + semver: 7.3.5 + transitivePeerDependencies: + - supports-color + dev: true + + /supertest/6.2.2: + resolution: {integrity: sha512-wCw9WhAtKJsBvh07RaS+/By91NNE0Wh0DN19/hWPlBOU8tAfOtbZoVSV4xXeoKoxgPx0rx2y+y+8660XtE7jzg==} + engines: {node: '>=6.0.0'} + dependencies: + methods: 1.1.2 + superagent: 7.1.1 + transitivePeerDependencies: + - supports-color + dev: true + /supports-color/5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'}