@labdigital/commercetools-mock 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@labdigital/commercetools-mock",
3
3
  "author": "Michael van Tellingen",
4
- "version": "2.0.0",
4
+ "version": "2.2.0",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.cjs",
7
7
  "module": "dist/index.js",
@@ -37,10 +37,10 @@
37
37
  "body-parser": "^1.20.2",
38
38
  "deep-equal": "^2.2.2",
39
39
  "express": "^4.18.2",
40
+ "light-my-request": "^5.11.0",
40
41
  "lodash.isequal": "^4.5.0",
41
42
  "morgan": "^1.10.0",
42
43
  "msw": "^2.0.0",
43
- "supertest": "^6.3.3",
44
44
  "uuid": "^9.0.0"
45
45
  },
46
46
  "devDependencies": {
@@ -67,6 +67,7 @@
67
67
  "got": "^11.8.3",
68
68
  "husky": "^8.0.3",
69
69
  "prettier": "^3.0.0",
70
+ "supertest": "^6.3.3",
70
71
  "timekeeper": "^2.3.1",
71
72
  "ts-node": "^10.9.1",
72
73
  "tslib": "^2.6.1",
package/src/ctMock.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import express, { NextFunction, Request, Response } from 'express'
2
- import supertest from 'supertest'
3
2
  import morgan from 'morgan'
3
+ import inject from 'light-my-request'
4
4
  import { setupServer, SetupServer } from 'msw/node'
5
5
  import { http, HttpResponse } from 'msw'
6
6
  import { AbstractStorage, InMemoryStorage } from './storage/index.js'
@@ -16,6 +16,7 @@ import { ProjectService } from './services/project.js'
16
16
  import { createRepositories, RepositoryMap } from './repositories/index.js'
17
17
  import { createServices } from './services/index.js'
18
18
  import { ProjectRepository } from './repositories/project.js'
19
+ import { mapHeaderType } from './helpers.js'
19
20
 
20
21
  export type CommercetoolsMockOptions = {
21
22
  validateCredentials: boolean
@@ -106,6 +107,7 @@ export class CommercetoolsMock {
106
107
 
107
108
  private createApp(options?: AppOptions): express.Express {
108
109
  this._repositories = createRepositories(this._storage)
110
+ this._oauth2.setCustomerRepository(this._repositories.customer)
109
111
 
110
112
  const app = express()
111
113
 
@@ -139,6 +141,14 @@ export class CommercetoolsMock {
139
141
 
140
142
  app.use((err: Error, req: Request, resp: Response, next: NextFunction) => {
141
143
  if (err instanceof CommercetoolsError) {
144
+ if (err.errors?.length > 0) {
145
+ return resp.status(err.statusCode).send({
146
+ statusCode: err.statusCode,
147
+ message: err.message,
148
+ errors: err.errors,
149
+ })
150
+ }
151
+
142
152
  return resp.status(err.statusCode).send({
143
153
  statusCode: err.statusCode,
144
154
  message: err.message,
@@ -166,56 +176,66 @@ export class CommercetoolsMock {
166
176
  }
167
177
  }
168
178
 
169
- const app = this.app
179
+ const server = this.app
170
180
  this._mswServer = setupServer(
171
181
  http.post(`${this.options.authHost}/oauth/*`, async ({ request }) => {
172
- const text = await request.text()
182
+ const body = await request.text()
173
183
  const url = new URL(request.url)
174
- const res = await supertest(app)
175
- .post(url.pathname + '?' + url.searchParams.toString())
176
- .set(copyHeaders(request.headers))
177
- .send(text)
184
+ const headers = copyHeaders(request.headers)
178
185
 
179
- return new HttpResponse(res.text, {
180
- status: res.status,
181
- headers: res.headers,
186
+ const res = await inject(server)
187
+ .post(url.pathname + '?' + url.searchParams.toString())
188
+ .body(body)
189
+ .headers(headers)
190
+ .end()
191
+ return new HttpResponse(res.body, {
192
+ status: res.statusCode,
193
+ headers: mapHeaderType(res.headers),
182
194
  })
183
195
  }),
184
196
  http.get(`${this.options.apiHost}/*`, async ({ request }) => {
185
197
  const body = await request.text()
186
198
  const url = new URL(request.url)
187
- const res = await supertest(app)
199
+ const headers = copyHeaders(request.headers)
200
+
201
+ const res = await inject(server)
188
202
  .get(url.pathname + '?' + url.searchParams.toString())
189
- .set(copyHeaders(request.headers))
190
- .send(body)
191
- return new HttpResponse(res.text, {
192
- status: res.status,
193
- headers: res.headers,
203
+ .body(body)
204
+ .headers(headers)
205
+ .end()
206
+ return new HttpResponse(res.body, {
207
+ status: res.statusCode,
208
+ headers: mapHeaderType(res.headers),
194
209
  })
195
210
  }),
196
211
  http.post(`${this.options.apiHost}/*`, async ({ request }) => {
197
212
  const body = await request.text()
198
213
  const url = new URL(request.url)
199
- const res = await supertest(app)
214
+ const headers = copyHeaders(request.headers)
215
+
216
+ const res = await inject(server)
200
217
  .post(url.pathname + '?' + url.searchParams.toString())
201
- .set(copyHeaders(request.headers))
202
- .send(body)
203
- return new HttpResponse(res.text, {
204
- status: res.status,
205
- headers: res.headers,
218
+ .body(body)
219
+ .headers(headers)
220
+ .end()
221
+ return new HttpResponse(res.body, {
222
+ status: res.statusCode,
223
+ headers: mapHeaderType(res.headers),
206
224
  })
207
225
  }),
208
226
  http.delete(`${this.options.apiHost}/*`, async ({ request }) => {
209
227
  const body = await request.text()
210
228
  const url = new URL(request.url)
211
- const res = await supertest(app)
212
- .delete(url.pathname + '?' + url.searchParams.toString())
213
- .set(copyHeaders(request.headers))
214
- .send(body)
229
+ const headers = copyHeaders(request.headers)
215
230
 
216
- return new HttpResponse(res.text, {
217
- status: res.status,
218
- headers: res.headers,
231
+ const res = await inject(server)
232
+ .delete(url.pathname + '?' + url.searchParams.toString())
233
+ .body(body)
234
+ .headers(headers)
235
+ .end()
236
+ return new HttpResponse(res.body, {
237
+ status: res.statusCode,
238
+ headers: mapHeaderType(res.headers),
219
239
  })
220
240
  })
221
241
  )
package/src/exceptions.ts CHANGED
@@ -1,15 +1,19 @@
1
1
  export abstract class BaseError {
2
2
  abstract message: string
3
+ abstract errors?: BaseError[]
3
4
  }
4
5
 
5
6
  export class CommercetoolsError<T extends BaseError> extends Error {
6
7
  info: T
7
8
  statusCode: number
8
9
 
10
+ errors: BaseError[]
11
+
9
12
  constructor(info: T, statusCode = 400) {
10
13
  super(info.message)
11
14
  this.info = info
12
15
  this.statusCode = statusCode || 500
16
+ this.errors = info.errors ?? []
13
17
  }
14
18
  }
15
19
 
package/src/helpers.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { OutgoingHttpHeaders } from 'node:http'
1
2
  import { ParsedQs } from 'qs'
2
3
  import { v4 as uuidv4 } from 'uuid'
3
4
 
@@ -59,3 +60,20 @@ export const queryParamsValue = (
59
60
  }
60
61
 
61
62
  export const cloneObject = <T>(o: T): T => JSON.parse(JSON.stringify(o))
63
+
64
+ export const mapHeaderType = (
65
+ outgoingHttpHeaders: OutgoingHttpHeaders
66
+ ): HeadersInit => {
67
+ const headersInit: HeadersInit = {}
68
+ for (const key in outgoingHttpHeaders) {
69
+ const value = outgoingHttpHeaders[key]
70
+ if (Array.isArray(value)) {
71
+ // Join multiple values for the same header with a comma
72
+ headersInit[key] = value.join(', ')
73
+ } else if (value !== undefined) {
74
+ // Single value or undefined
75
+ headersInit[key] = value.toString()
76
+ }
77
+ }
78
+ return headersInit
79
+ }
@@ -0,0 +1,7 @@
1
+ export const validatePassword = (
2
+ clearPassword: string,
3
+ hashedPassword: string
4
+ ) => hashPassword(clearPassword) === hashedPassword
5
+
6
+ export const hashPassword = (clearPassword: string) =>
7
+ Buffer.from(clearPassword).toString('base64')
@@ -10,18 +10,45 @@ import { CommercetoolsError, InvalidRequestError } from '../exceptions.js'
10
10
  import { InvalidClientError, UnsupportedGrantType } from './errors.js'
11
11
  import { OAuth2Store } from './store.js'
12
12
  import { getBearerToken } from './helpers.js'
13
+ import { CustomerRepository } from '../repositories/customer.js'
14
+ import { hashPassword } from '../lib/password.js'
15
+
16
+ type AuthRequest = Request & {
17
+ credentials: {
18
+ clientId: string
19
+ clientSecret: string
20
+ }
21
+ }
13
22
 
14
23
  export class OAuth2Server {
15
24
  store: OAuth2Store
25
+ private customerRepository: CustomerRepository
16
26
 
17
27
  constructor(options: { enabled: boolean; validate: boolean }) {
18
28
  this.store = new OAuth2Store(options.validate)
19
29
  }
20
30
 
31
+ setCustomerRepository(repository: CustomerRepository) {
32
+ this.customerRepository = repository
33
+ }
34
+
21
35
  createRouter() {
22
36
  const router = express.Router()
23
37
  router.use(bodyParser.urlencoded({ extended: true }))
38
+ router.use(this.validateClientCredentials.bind(this))
24
39
  router.post('/token', this.tokenHandler.bind(this))
40
+ router.post(
41
+ '/:projectKey/customers/token',
42
+ this.customerTokenHandler.bind(this)
43
+ )
44
+ router.post(
45
+ '/:projectKey/in-store/key=:storeKey/customers/token',
46
+ this.inStoreCustomerTokenHandler.bind(this)
47
+ )
48
+ router.post(
49
+ '/:projectKey/anonymous/token',
50
+ this.anonymousTokenHandler.bind(this)
51
+ )
25
52
  return router
26
53
  }
27
54
 
@@ -56,7 +83,12 @@ export class OAuth2Server {
56
83
  next()
57
84
  }
58
85
  }
59
- async tokenHandler(request: Request, response: Response, next: NextFunction) {
86
+
87
+ async validateClientCredentials(
88
+ request: AuthRequest,
89
+ response: Response,
90
+ next: NextFunction
91
+ ) {
60
92
  const authHeader = request.header('Authorization')
61
93
  if (!authHeader) {
62
94
  return next(
@@ -84,6 +116,19 @@ export class OAuth2Server {
84
116
  )
85
117
  }
86
118
 
119
+ request.credentials = {
120
+ clientId: credentials.name,
121
+ clientSecret: credentials.pass,
122
+ }
123
+
124
+ next()
125
+ }
126
+
127
+ async tokenHandler(
128
+ request: AuthRequest,
129
+ response: Response,
130
+ next: NextFunction
131
+ ) {
87
132
  const grantType = request.query.grant_type || request.body.grant_type
88
133
  if (!grantType) {
89
134
  return next(
@@ -99,8 +144,15 @@ export class OAuth2Server {
99
144
 
100
145
  if (grantType === 'client_credentials') {
101
146
  const token = this.store.getClientToken(
102
- credentials.name,
103
- credentials.pass,
147
+ request.credentials.clientId,
148
+ request.credentials.clientSecret,
149
+ request.query.scope?.toString()
150
+ )
151
+ return response.status(200).send(token)
152
+ } else if (grantType === 'refresh_token') {
153
+ const token = this.store.getClientToken(
154
+ request.credentials.clientId,
155
+ request.credentials.clientSecret,
104
156
  request.query.scope?.toString()
105
157
  )
106
158
  return response.status(200).send(token)
@@ -116,4 +168,86 @@ export class OAuth2Server {
116
168
  )
117
169
  }
118
170
  }
171
+ async customerTokenHandler(
172
+ request: AuthRequest,
173
+ response: Response,
174
+ next: NextFunction
175
+ ) {
176
+ const grantType = request.query.grant_type || request.body.grant_type
177
+ if (!grantType) {
178
+ return next(
179
+ new CommercetoolsError<InvalidRequestError>(
180
+ {
181
+ code: 'invalid_request',
182
+ message: 'Missing required parameter: grant_type.',
183
+ },
184
+ 400
185
+ )
186
+ )
187
+ }
188
+
189
+ if (grantType === 'password') {
190
+ const username = request.query.username || request.body.username
191
+ const password = hashPassword(
192
+ request.query.password || request.body.password
193
+ )
194
+ const scope =
195
+ request.query.scope?.toString() || request.body.scope?.toString()
196
+
197
+ const result = this.customerRepository.query(
198
+ { projectKey: request.params.projectKey },
199
+ {
200
+ where: [`email = "${username}"`, `password = "${password}"`],
201
+ }
202
+ )
203
+
204
+ if (result.count === 0) {
205
+ return next(
206
+ new CommercetoolsError<any>(
207
+ {
208
+ code: 'invalid_customer_account_credentials',
209
+ message: 'Customer account with the given credentials not found.',
210
+ },
211
+ 400
212
+ )
213
+ )
214
+ }
215
+
216
+ const customer = result.results[0]
217
+ const token = this.store.getCustomerToken(scope, customer.id)
218
+ return response.status(200).send(token)
219
+ }
220
+ }
221
+
222
+ async inStoreCustomerTokenHandler(
223
+ request: Request,
224
+ response: Response,
225
+ next: NextFunction
226
+ ) {
227
+ return next(
228
+ new CommercetoolsError<InvalidClientError>(
229
+ {
230
+ code: 'invalid_client',
231
+ message: 'Not implemented yet in commercetools-mock',
232
+ },
233
+ 401
234
+ )
235
+ )
236
+ }
237
+
238
+ async anonymousTokenHandler(
239
+ request: Request,
240
+ response: Response,
241
+ next: NextFunction
242
+ ) {
243
+ return next(
244
+ new CommercetoolsError<InvalidClientError>(
245
+ {
246
+ code: 'invalid_client',
247
+ message: 'Not implemented yet in commercetools-mock',
248
+ },
249
+ 401
250
+ )
251
+ )
252
+ }
119
253
  }
@@ -26,6 +26,19 @@ export class OAuth2Store {
26
26
  return token
27
27
  }
28
28
 
29
+ getCustomerToken(scope: string, customerId: string) {
30
+ const token: Token = {
31
+ access_token: randomBytes(16).toString('base64'),
32
+ token_type: 'Bearer',
33
+ expires_in: 172800,
34
+ scope: scope
35
+ ? `${scope} custome_id:${customerId}`
36
+ : `customer_id: ${customerId}`,
37
+ }
38
+ this.tokens.push(token)
39
+ return token
40
+ }
41
+
29
42
  validateToken(token: string) {
30
43
  if (!this.validate) return true
31
44
 
@@ -4,6 +4,7 @@ import type {
4
4
  CustomerDraft,
5
5
  CustomerSetAuthenticationModeAction,
6
6
  CustomerSetCustomFieldAction,
7
+ DuplicateFieldError,
7
8
  InvalidInputError,
8
9
  InvalidJsonInputError,
9
10
  } from '@commercetools/platform-sdk'
@@ -14,6 +15,7 @@ import {
14
15
  AbstractResourceRepository,
15
16
  type RepositoryContext,
16
17
  } from './abstract.js'
18
+ import { hashPassword } from '../lib/password.js'
17
19
 
18
20
  export class CustomerRepository extends AbstractResourceRepository<'customer'> {
19
21
  getTypeId() {
@@ -21,13 +23,32 @@ export class CustomerRepository extends AbstractResourceRepository<'customer'> {
21
23
  }
22
24
 
23
25
  create(context: RepositoryContext, draft: CustomerDraft): Customer {
26
+ // Check uniqueness
27
+ const results = this._storage.query(context.projectKey, this.getTypeId(), {
28
+ where: [`email="${draft.email.toLocaleLowerCase()}"`],
29
+ })
30
+ if (results.count > 0) {
31
+ throw new CommercetoolsError<any>({
32
+ code: 'CustomerAlreadyExists',
33
+ statusCode: 400,
34
+ message:
35
+ 'There is already an existing customer with the provided email.',
36
+ errors: [
37
+ {
38
+ code: 'DuplicateField',
39
+ message: `Customer with email '${draft.email}' already exists.`,
40
+ duplicateValue: draft.email,
41
+ field: 'email',
42
+ } as DuplicateFieldError,
43
+ ],
44
+ })
45
+ }
46
+
24
47
  const resource: Customer = {
25
48
  ...getBaseResourceProperties(),
26
49
  authenticationMode: draft.authenticationMode || 'Password',
27
- email: draft.email,
28
- password: draft.password
29
- ? Buffer.from(draft.password).toString('base64')
30
- : undefined,
50
+ email: draft.email.toLowerCase(),
51
+ password: draft.password ? hashPassword(draft.password) : undefined,
31
52
  isEmailVerified: draft.isEmailVerified || false,
32
53
  addresses: [],
33
54
  }
@@ -36,11 +57,14 @@ export class CustomerRepository extends AbstractResourceRepository<'customer'> {
36
57
  }
37
58
 
38
59
  getMe(context: RepositoryContext): Customer | undefined {
60
+ // grab the first customer you can find for now. In the future we should
61
+ // use the customer id from the scope of the token
39
62
  const results = this._storage.query(
40
63
  context.projectKey,
41
64
  this.getTypeId(),
42
65
  {}
43
- ) // grab the first customer you can find
66
+ )
67
+
44
68
  if (results.count > 0) {
45
69
  return results.results[0] as Customer
46
70
  }
@@ -76,9 +100,7 @@ export class CustomerRepository extends AbstractResourceRepository<'customer'> {
76
100
  return
77
101
  }
78
102
  if (authMode === 'Password') {
79
- resource.password = password
80
- ? Buffer.from(password).toString('base64')
81
- : undefined
103
+ resource.password = password ? hashPassword(password) : undefined
82
104
  return
83
105
  }
84
106
  throw new CommercetoolsError<InvalidJsonInputError>(
@@ -2,6 +2,7 @@ import { Request, Response, Router } from 'express'
2
2
  import { CustomerRepository } from '../repositories/customer.js'
3
3
  import { getRepositoryContext } from '../repositories/helpers.js'
4
4
  import AbstractService from './abstract.js'
5
+ import { hashPassword } from '../lib/password.js'
5
6
 
6
7
  export class MyCustomerService extends AbstractService {
7
8
  public repository: CustomerRepository
@@ -51,7 +52,7 @@ export class MyCustomerService extends AbstractService {
51
52
 
52
53
  signIn(request: Request, response: Response) {
53
54
  const { email, password } = request.body
54
- const encodedPassword = Buffer.from(password).toString('base64')
55
+ const encodedPassword = hashPassword(password)
55
56
 
56
57
  const result = this.repository.query(getRepositoryContext(request), {
57
58
  where: [`email = "${email}"`, `password = "${encodedPassword}"`],