diff --git a/.gitignore b/.gitignore index 0a811ca..645d3a5 100644 --- a/.gitignore +++ b/.gitignore @@ -112,3 +112,4 @@ dist **/.idea/tasks.xml **/.idea/shelf **/.idea/*.iml +/tmp/ diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..a55e7a1 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/dataSources.local.xml b/.idea/dataSources.local.xml new file mode 100644 index 0000000..ca0dc03 --- /dev/null +++ b/.idea/dataSources.local.xml @@ -0,0 +1,15 @@ + + + + + + + master_key + + + + + + + + \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..54163ef --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,11 @@ + + + + + mongo + true + com.dbschema.MongoJdbcDriver + mongodb://localhost:27017 + + + \ No newline at end of file diff --git a/.idea/dataSources/46f112fc-d60d-4217-873f-f5ffea06180c.xml b/.idea/dataSources/46f112fc-d60d-4217-873f-f5ffea06180c.xml new file mode 100644 index 0000000..1619391 --- /dev/null +++ b/.idea/dataSources/46f112fc-d60d-4217-873f-f5ffea06180c.xml @@ -0,0 +1,584 @@ + + + + + 4.2.5 + + + + + + + + 1 + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + String(0)|12s + + + 4 + String(0)|12s + + + 5 + String(0)|12s + + + 6 + String(0)|12s + + + 7 + list(0)|4999545s + + + 8 + ObjectId(0)|12s + + + 9 + String(0)|12s + + + 10 + Double(0)|8s + + + 11 + String(0)|12s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + list(0)|4999545s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + String(0)|12s + + + 4 + ObjectId(0)|12s + + + 5 + ObjectId(0)|12s + + + 6 + String(0)|12s + + + 7 + String(0)|12s + + + 8 + ObjectId(0)|12s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + String(0)|12s + + + 4 + String(0)|12s + + + 5 + String(0)|12s + + + 6 + String(0)|12s + + + 7 + String(0)|12s + + + 8 + String(0)|12s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + Integer|4s + + + 3 + Integer|4s + + + 4 + String(0)|12s + + + 5 + Integer|4s + + + 6 + String(0)|12s + + + 7 + list(0)|4999545s + + + 8 + String(0)|12s + + + 9 + Double(0)|8s + + + 10 + String(0)|12s + + + ObjectId(0)|12s + + + 1 + String(0)|12s + + + 2 + list(0)|4999545s + + + 3 + String(0)|12s + + + 4 + map(0)|4999544s + + + 5 + Double(0)|8s + + + 6 + Integer|4s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + Integer|4s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + map(0)|4999544s + + + 4 + String(0)|12s + + + 5 + Boolean|12s + + + 6 + list(0)|4999545s + + + 7 + String(0)|12s + + + 8 + String(0)|12s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + String(0)|12s + + + 4 + ObjectId(0)|12s + + + 5 + String(0)|12s + + + 6 + String(0)|12s + + + 7 + String(0)|12s + + + 8 + ObjectId(0)|12s + + + 9 + Boolean|12s + + + ObjectId(0)|12s + + + 1 + String(0)|12s + + + 2 + list(0)|4999545s + + + 3 + String(0)|12s + + + 4 + map(0)|4999544s + + + 5 + Integer|4s + + + 6 + Integer|4s + + + 7 + array(0)|2003s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + String(0)|12s + + + 4 + String(0)|12s + + + 5 + String(0)|12s + + + 6 + String(0)|12s + + + 7 + String(0)|12s + + + 8 + String(0)|12s + + + String(0)|12s + + + 1 + map(0)|4999544s + + + 2 + String(0)|12s + + + 3 + Integer|4s + + + 4 + map(0)|4999544s + + + 5 + String(0)|12s + + + 6 + String(0)|12s + + + 7 + String(0)|12s + + + 8 + String(0)|12s + + + 9 + String(0)|12s + + + 10 + String(0)|12s + + + 11 + String(0)|12s + + + 12 + String(0)|12s + + + 13 + String(0)|12s + + + 14 + Boolean|12s + + + 15 + String(0)|12s + + + 16 + String(0)|12s + + + 17 + Integer|4s + + + 18 + list(0)|4999545s + + + 19 + map(0)|4999544s + + + 20 + String(0)|12s + + + 21 + array(0)|2003s + + + 22 + String(0)|12s + + + 23 + String(0)|12s + + + 24 + String(0)|12s + + + 25 + array(0)|2003s + + + 26 + map(0)|4999544s + + + 27 + String(0)|12s + + + 28 + map(0)|4999544s + + + 29 + String(0)|12s + + + 30 + Integer|4s + + + 31 + Boolean|12s + + + 32 + map(0)|4999544s + + + 33 + String(0)|12s + + + 34 + map(0)|4999544s + + + 35 + Boolean|12s + + + 36 + map(0)|4999544s + + + 37 + String(0)|12s + + + 38 + Boolean|12s + + + 39 + String(0)|12s + + + 40 + String(0)|12s + + + 41 + Long(0)|12s + + + 42 + Date(0)|91s + + + 43 + String(0)|12s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + Integer|4s + + + ObjectId(0)|12s + + + 1 + String(0)|12s + + + \ No newline at end of file diff --git a/.idea/dbnavigator.xml b/.idea/dbnavigator.xml new file mode 100644 index 0000000..ad4eaf6 --- /dev/null +++ b/.idea/dbnavigator.xml @@ -0,0 +1,458 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/dictionaries/VLE2FE.xml b/.idea/dictionaries/VLE2FE.xml new file mode 100644 index 0000000..5337928 --- /dev/null +++ b/.idea/dictionaries/VLE2FE.xml @@ -0,0 +1,12 @@ + + + + bcrypt + cfenv + dfopdb + janedoe + pagesize + testcomment + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index c947305..7e46df7 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -2,5 +2,6 @@ \ No newline at end of file diff --git a/oas/oas.yaml b/api/api.yaml similarity index 57% rename from oas/oas.yaml rename to api/api.yaml index 81e06bf..a1966fa 100644 --- a/oas/oas.yaml +++ b/api/api.yaml @@ -6,7 +6,10 @@ info: version: 1.0.0 description: | This API gives access to the project database.
- Access is restricted. Authentication can be obtained with HTTP Basic Auth using username and password. Data access methods can also be accessed using an API key at the URL ending like ?key=xxx
+ Access is restricted. Authentication can be obtained with HTTP Basic Auth using username and password. + Data access methods can also be accessed using an API key at the URL ending like ?key=xxx
+ The description lists available authentication methods, also the locks of each method close correspondingly + if the entered authentication is allowed.

There are a number of different user levels:
  • read: read access to the samples database
  • @@ -15,14 +18,31 @@ info:
  • dev: handling machine learning models
  • admin: user administration
- + Password policy: +
    +
  • at least one digit
  • +
  • at least one lower case letter
  • +
  • at least one upper case letter
  • +
  • at least one of the following special characters: !"#%&'()*+,-./:;<=>?@[\]^_`{|}~
  • +
  • no whitespace
  • +
  • at least 8 characters
  • +
+ x-doc: | + status: +
    +
  • -10: deleted
  • +
  • 0: newly added/changed
  • +
  • 10: validated
  • +
+ Bitbucket repository +# TODO: Link to new documentation page servers: + - url: https://definma-api.apps.de1.bosch-iot-cloud.com + description: server on the BIC - url: http://localhost:3000 description: local server - - url: https://digital-fingerprint-of-plastics-api.apps.de1.bosch-iot-cloud.com/ - description: server on the BIC security: @@ -34,19 +54,17 @@ tags: - name: / - name: /sample - name: /material - - name: /condition - name: /measurement - - name: /templates + - name: /template - name: /model - name: /user paths: allOf: - - $ref: 'others.yaml' + - $ref: 'root.yaml' - $ref: 'sample.yaml' - $ref: 'material.yaml' - - $ref: 'condition.yaml' - $ref: 'measurement.yaml' - $ref: 'template.yaml' - $ref: 'model.yaml' diff --git a/api/material.yaml b/api/material.yaml new file mode 100644 index 0000000..593afb1 --- /dev/null +++ b/api/material.yaml @@ -0,0 +1,248 @@ +/materials: + get: + summary: lists all materials + description: 'Auth: all, levels: read, write, maintain, dev, admin' + x-doc: returns only materials with status 10 + tags: + - /material + parameters: + - name: status + description: 'values: validated|new|all, defaults to validated' + in: query + schema: + type: string + example: all + responses: + 200: + description: all material details + content: + application/json: + schema: + type: array + items: + $ref: 'api.yaml#/components/schemas/Material' + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' + +/materials/{state}: + parameters: + - $ref: 'api.yaml#/components/parameters/State' + get: + summary: lists all new/deleted materials + description: 'Auth: basic, levels: maintain, admin' + x-doc: returns materials with status 0/-1 + tags: + - /material + responses: + 200: + description: all material details + content: + application/json: + schema: + type: array + items: + $ref: 'api.yaml#/components/schemas/Material' + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' + +/material/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + get: + summary: get material details + description: 'Auth: all, levels: read, write, maintain, dev, admin' + x-doc: deleted samples are available only for maintain/admin + tags: + - /material + responses: + 200: + description: material details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Material' + 401: + $ref: 'api.yaml#/components/responses/401' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + put: + summary: change material + description: 'Auth: basic, levels: write, maintain, dev, admin' + x-doc: status is reset to 0 on any changes, deleted samples cannot be changed + tags: + - /material + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Material' + responses: + 200: + description: material details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Material' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + delete: + summary: delete material + description: 'Auth: basic, levels: write, maintain, dev, admin' + x-doc: sets status to -1 + tags: + - /material + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/material/restore/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: restore material + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 0 + tags: + - /material + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/material/validate/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: restore material + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 10 + tags: + - /material + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/material/new: + post: + summary: add material + description: 'Auth: basic, levels: write, maintain, dev, admin' + x-doc: 'Adds status: 0 automatically' + tags: + - /material + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Material' + responses: + 200: + description: material details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Material' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' + +/material/groups: + get: + summary: list all existing material groups + description: 'Auth: all, levels: read, write, maintain, dev, admin' + tags: + - /material + responses: + 200: + description: all material groups + content: + application/json: + schema: + type: array + items: + type: string + example: PA66 + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' + +/material/suppliers: + get: + summary: list all existing material suppliers + description: 'Auth: all, levels: read, write, maintain, dev, admin' + tags: + - /material + responses: + 200: + description: all material suppliers + content: + application/json: + schema: + type: array + items: + type: string + example: BASF + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/api/measurement.yaml b/api/measurement.yaml new file mode 100644 index 0000000..0c29e77 --- /dev/null +++ b/api/measurement.yaml @@ -0,0 +1,155 @@ +/measurement/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + get: + summary: measurement values by id + description: 'Auth: all, levels: read, write, maintain, dev, admin' + x-doc: deleted samples are available only for maintain/admin + tags: + - /measurement + responses: + 200: + description: measurement details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Measurement' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + put: + summary: change measurement + description: 'Auth: basic, levels: write, maintain, dev, admin' + x-doc: status is reset to 0 on any changes, deleted measurements cannot be edited + tags: + - /measurement + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + properties: + values: + type: object + responses: + 200: + description: measurement details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Measurement' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + delete: + summary: delete measurement + description: 'Auth: basic, levels: write, maintain, dev, admin' + x-doc: sets status to -1 + tags: + - /measurement + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/measurement/restore/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: restore measurement + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 0 + tags: + - /measurement + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/measurement/validate/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: set measurement status to validated + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 10 + tags: + - /measurement + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/measurement/new: + post: + summary: add measurement + description: 'Auth: basic, levels: write, maintain, dev, admin' + x-doc: 'Adds status: 0 automatically' + tags: + - /measurement + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Measurement' + responses: + 200: + description: measurement details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Measurement' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' diff --git a/api/model.yaml b/api/model.yaml new file mode 100644 index 0000000..f9c3d72 --- /dev/null +++ b/api/model.yaml @@ -0,0 +1,70 @@ +/model/{name}: + parameters: + - $ref: 'api.yaml#/components/parameters/Name' + get: + summary: TODO get model data by name + description: 'Auth: all, levels: dev, admin' + tags: + - /model + responses: + 200: + description: binary model data + content: + application/octet-stream: + schema: + type: string + format: binary + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + put: + summary: TODO add/replace model data by name + description: 'Auth: all, levels: dev, admin' + tags: + - /model + requestBody: + required: true + description: binary model data + content: + application/json: + schema: + type: string + format: binary + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + delete: + summary: TODO delete model data + description: 'Auth: basic, levels: dev, admin' + tags: + - /model + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/api/parameters.yaml b/api/parameters.yaml new file mode 100644 index 0000000..3cbe49b --- /dev/null +++ b/api/parameters.yaml @@ -0,0 +1,24 @@ +Id: + name: id + in: path + required: true + schema: + type: string + example: 5ea0450ed851c30a90e70894 + +Name: + name: name + description: has to be URL encoded + in: path + required: true + schema: + type: string + +State: + name: group + description: 'possible values: new, deleted' + in: path + required: true + schema: + type: string + example: deleted \ No newline at end of file diff --git a/oas/responses.yaml b/api/responses.yaml similarity index 100% rename from oas/responses.yaml rename to api/responses.yaml diff --git a/api/root.yaml b/api/root.yaml new file mode 100644 index 0000000..3070412 --- /dev/null +++ b/api/root.yaml @@ -0,0 +1,102 @@ +/: + get: + summary: Root method + description: 'Auth: none' + tags: + - / + security: [] + responses: + 200: + description: Server is working + content: + application/json: + schema: + properties: + status: + type: string + example: 'API server up and running!' + 500: + $ref: 'api.yaml#/components/responses/500' + +/authorized: + get: + summary: Checks authorization + description: 'Auth: all, levels: read, write, maintain, dev, admin' + tags: + - / + responses: + 200: + description: Authorized + content: + application/json: + schema: + properties: + status: + type: string + example: 'Authorization successful' + method: + type: string + example: 'basic' + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' + +/changelog/{timestamp}/{page}/{pagesize}: + parameters: + - name: timestamp + in: path + required: true + schema: + type: string + example: 1970-01-01T00:00:00.000Z + - name: page + in: path + required: true + schema: + type: number + example: 3 + - name: pagesize + in: path + required: true + schema: + type: number + example: 30 + get: + summary: get changelog + description: 'Auth: basic, levels: maintain, admin
Displays all logs older than timestamp, sorted by date descending, page defaults to 0, pagesize defaults to 25
Avoid using high page numbers for older logs, better use an older timestamp' + tags: + - / + responses: + 200: + description: Changelog + content: + application/json: + schema: + properties: + date: + type: string + example: 1970-01-01T00:00:00.000Z + action: + type: string + example: PUT /sample/400000000000000000000001 + collection: + type: string + example: samples + conditions: + type: object + example: + _id: '400000000000000000000001' + data: + type: object + example: + type: part + status: 0 + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/api/sample.yaml b/api/sample.yaml new file mode 100644 index 0000000..2b0ce31 --- /dev/null +++ b/api/sample.yaml @@ -0,0 +1,312 @@ +/samples: + get: + summary: all samples in overview + description: 'Auth: all, levels: read, write, maintain, dev, admin' + x-doc: 'Limitations: paging and csv output does not work when including the spectrum measurement fields as well as the returned number of total samples' + tags: + - /sample + parameters: + - name: status + description: 'values: validated|new|all, defaults to validated' + in: query + schema: + type: string + example: all + - name: from-id + description: first id of the requested page, if not given the results are displayed from start + in: query + schema: + type: string + example: 5ea0450ed851c30a90e70894 + - name: to-page + description: relative change of pages, use negative values to get back, defaults to 0, works only together with page-size + in: query + schema: + type: string + example: 1 + - name: page-size + description: number of items per page + in: query + schema: + type: string + example: 30 + - name: sort + description: sorting of results, in format 'key-asc/desc' + in: query + schema: + type: string + example: color-asc + - name: csv + description: output as csv + in: query + schema: + type: boolean + example: false + - name: fields[] + description: the fields to include in the output as array, defaults to ['_id','number','type','batch','material_id','color','condition','note_id','user_id','added'] + in: query + schema: + type: array + items: + type: string + example: ['number', 'batch'] + - name: filters[] + description: "the filters to apply as an array of URIComponent encoded objects in the form {mode: 'eq/ne/lt/lte/gt/gte/in/nin', field: 'material.m', values: ['15']} using encodeURIComponent(JSON.stringify({}))" + in: query + schema: + type: array + items: + type: string + example: ["%7B%22mode%22%3A%22eq%22%2C%22field%22%3A%22material.m%22%2C%22values%22%3A%5B%2215%22%5D%7D", "%7B%22mode%22%3A%22isin%22%2C%22field%22%3A%22material.supplier%22%2C%22values%22%3A%5B%22BASF%22%2C%22DSM%22%5D%7D"] + responses: + 200: + description: samples overview (if the csv parameter is set, this is in CSV instead of JSON format) + headers: + x-total-items: + description: Total number of available items when from-id is not specified and spectrum field is not included + schema: + type: integer + example: 243 + content: + application/json: + schema: + type: array + items: + $ref: 'api.yaml#/components/schemas/SampleRefs' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/samples/{state}: + parameters: + - $ref: 'api.yaml#/components/parameters/State' + get: + summary: all new/deleted samples in overview + description: 'Auth: basic, levels: maintain, admin' + x-doc: returns only samples with status 0/-1 + tags: + - /sample + responses: + 200: + description: samples overview + content: + application/json: + schema: + type: array + items: + $ref: 'api.yaml#/components/schemas/SampleRefs' + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' + +/samples/count: + get: + summary: total number of samples + description: 'Auth: all, levels: read, write, maintain, dev, admin' + tags: + - /sample + responses: + 200: + description: sample count + content: + application/json: + schema: + properties: + count: + type: number + example: 864 + 500: + $ref: 'api.yaml#/components/responses/500' + +/sample/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + get: + summary: sample details + description: 'Auth: all, levels: read, write, maintain, dev, admin
Returns validated as well as new measurements' + x-doc: deleted samples are available only for maintain/admin + tags: + - /sample + responses: + 200: + description: samples details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/SampleDetail' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + put: + summary: change sample + description: 'Auth: basic, levels: write, maintain, dev, admin
Only maintain and admin are allowed to edit samples created by another user' + x-doc: status is reset to 0 on any changes, deleted samples cannot be changed + tags: + - /sample + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Sample' + responses: + 200: + description: samples details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/SampleRefs' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + delete: + summary: delete sample + description: 'Auth: basic, levels: write, maintain, dev, admin
Only maintain and admin are allowed to edit samples created by another user' + x-doc: sets status to -1, notes and references to this sample are also kept, only note_fields are updated accordingly + tags: + - /sample + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/sample/restore/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: restore sample + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 0 + tags: + - /sample + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/sample/validate/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: set sample status to validated + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 10 + tags: + - /sample + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/sample/new: + post: + summary: add sample + description: 'Auth: basic, levels: write, maintain, dev, admin. Number property is only for admin when adding existing samples' + x-doc: 'Adds status: 0 automatically' + tags: + - /sample + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: 'api.yaml#/components/schemas/Sample' + properties: + number: + type: string + readOnly: false + responses: + 200: + description: samples details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/SampleRefs' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' + +/sample/notes/fields: + get: + summary: list all existing field names for custom notes fields + description: 'Auth: all, levels: read, write, maintain, dev, admin' + x-doc: integrity has to be ensured + tags: + - /sample + responses: + 200: + description: field names and quantity of usage + content: + application/json: + schema: + type: array + items: + properties: + name: + type: string + qty: + type: number + example: 20 + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/api/schemas.yaml b/api/schemas.yaml new file mode 100644 index 0000000..99f7998 --- /dev/null +++ b/api/schemas.yaml @@ -0,0 +1,202 @@ +Id: + type: string + example: 5ea0450ed851c30a90e70894 +_Id: + properties: + _id: + allOf: + - $ref: 'api.yaml#/components/schemas/Id' + readOnly: true +Color: + properties: + color: + type: string + example: black +SampleProperties: + properties: + number: + type: string + readOnly: true + example: Rng172 + type: + type: string + example: granulate + batch: + type: string + example: 1560237365 + condition: + type: object + properties: + condition_template: + $ref: 'api.yaml#/components/schemas/Id' + example: + condition_template: 5ea0450ed851c30a90e70894 + material: hot air + weeks: 5 + +SampleRefs: + allOf: + - $ref: 'api.yaml#/components/schemas/_Id' + - $ref: 'api.yaml#/components/schemas/Color' + - $ref: 'api.yaml#/components/schemas/SampleProperties' + properties: + material_id: + $ref: 'api.yaml#/components/schemas/Id' + note_id: + $ref: 'api.yaml#/components/schemas/Id' + user_id: + $ref: 'api.yaml#/components/schemas/Id' + added: + type: string + example: 1970-01-01T00:00:00.000Z +Sample: + allOf: + - $ref: 'api.yaml#/components/schemas/_Id' + - $ref: 'api.yaml#/components/schemas/Color' + - $ref: 'api.yaml#/components/schemas/SampleProperties' + properties: + material_id: + allOf: + - $ref: 'api.yaml#/components/schemas/Id' + notes: + type: object + properties: + comment: + type: string + sample_references: + type: array + items: + properties: + sample_id: + $ref: 'api.yaml#/components/schemas/Id' + relation: + type: string + example: part to this sample + custom_fields: + type: object + +SampleDetail: + allOf: + - $ref: 'api.yaml#/components/schemas/_Id' + - $ref: 'api.yaml#/components/schemas/Color' + - $ref: 'api.yaml#/components/schemas/SampleProperties' + properties: + material: + allOf: + - $ref: 'api.yaml#/components/schemas/Material' + notes: + type: object + properties: + comment: + type: string + sample_references: + type: array + items: + $ref: 'api.yaml#/components/schemas/Id' + measurements: + type: array + items: + allOf: + - $ref: 'api.yaml#/components/schemas/Measurement' + user: + type: string + example: admin + +Material: + allOf: + - $ref: 'api.yaml#/components/schemas/_Id' + properties: + name: + type: string + example: Stanyl TW 200 F8 + supplier: + type: string + example: DSM + group: + type: string + example: PA46 + mineral: + type: number + example: 0 + glass_fiber: + type: number + example: 40 + carbon_fiber: + type: number + example: 0 + numbers: + type: array + items: + type: object + allOf: + - $ref: 'api.yaml#/components/schemas/Color' + properties: + number: + type: string + example: 5514263423 + +Measurement: + allOf: + - $ref: 'api.yaml#/components/schemas/_Id' + properties: + sample_id: + $ref: 'api.yaml#/components/schemas/Id' + values: + type: object + measurement_template: + $ref: 'api.yaml#/components/schemas/Id' + +Template: + allOf: + - $ref: 'api.yaml#/components/schemas/_Id' + properties: + name: + type: string + example: humidity + version: + type: number + readOnly: true + example: 1 + parameters: + type: array + items: + type: object + properties: + name: + type: string + example: kf + range: + type: object + example: + min: 0 + max: 2 + +Email: + properties: + email: + type: string + example: john.doe@bosch.com +UserName: + properties: + name: + type: string + example: johndoe +User: + allOf: + - $ref: 'api.yaml#/components/schemas/_Id' + - $ref: 'api.yaml#/components/schemas/UserName' + - $ref: 'api.yaml#/components/schemas/Email' + properties: + pass: + type: string + writeOnly: true + example: Abc123!# + level: + type: string + example: read + location: + type: string + example: Rng + device_name: + type: string + example: Alpha II diff --git a/api/template.yaml b/api/template.yaml new file mode 100644 index 0000000..4fa938d --- /dev/null +++ b/api/template.yaml @@ -0,0 +1,213 @@ +/template/conditions: + get: + summary: all available condition methods + description: 'Auth: basic, levels: read, write, maintain, dev, admin' + tags: + - /template + security: + - BasicAuth: [] + responses: + 200: + description: list of conditions + content: + application/json: + schema: + type: array + items: + $ref: 'api.yaml#/components/schemas/Template' + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' + +/template/condition/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + get: + summary: condition method details + description: 'Auth: basic, levels: read, write, maintain, admin' + tags: + - /template + security: + - BasicAuth: [] + responses: + 200: + description: condition details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Template' + 401: + $ref: 'api.yaml#/components/responses/401' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + put: + summary: change condition method + description: 'Auth: basic, levels: maintain, admin' + x-doc: With a change a new version is set, resulting in a new template with a new id + tags: + - /template + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Template' + responses: + 200: + description: condition details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Template' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/template/condition/new: + post: + summary: add condition method + description: 'Auth: basic, levels: maintain, admin' + tags: + - /template + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Template' + responses: + 200: + description: condition details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Template' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' + +/template/measurements: + get: + summary: all available measurement methods + description: 'Auth: basic, levels: read, write, maintain, dev, admin' + tags: + - /template + security: + - BasicAuth: [] + responses: + 200: + description: list of measurement methods + content: + application/json: + schema: + type: array + items: + $ref: 'api.yaml#/components/schemas/Template' + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' +/template/measurement/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + get: + summary: measurement method details + description: 'Auth: basic, levels: read, write, maintain, admin' + tags: + - /template + security: + - BasicAuth: [] + responses: + 200: + description: measurement details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Template' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + put: + summary: change measurement method + description: 'Auth: basic, levels: maintain, admin' + tags: + - /template + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Template' + responses: + 200: + description: measurement details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Template' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/template/measurement/new: + post: + summary: add measurement method + description: 'Auth: basic, levels: maintain, admin' + tags: + - /template + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Template' + responses: + 200: + description: measurement details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Template' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/api/user.yaml b/api/user.yaml new file mode 100644 index 0000000..757ebf0 --- /dev/null +++ b/api/user.yaml @@ -0,0 +1,255 @@ +/users: + get: + summary: lists all users + description: 'Auth: basic, levels: admin' + tags: + - /user + security: + - BasicAuth: [] + responses: + 200: + description: user API key + content: + application/json: + schema: + type: array + items: + $ref: 'api.yaml#/components/schemas/User' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' +/user: + get: + summary: list own user details + description: 'Auth: basic, levels: read, write, maintain, admin' + tags: + - /user + security: + - BasicAuth: [] + responses: + 200: + description: user details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/User' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' + put: + summary: change user details + description: 'Auth: basic, levels: read, write, maintain, admin' + tags: + - /user + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: 'api.yaml#/components/schemas/_Id' + - $ref: 'api.yaml#/components/schemas/UserName' + - $ref: 'api.yaml#/components/schemas/Email' + properties: + pass: + type: string + writeOnly: true + example: Abc123!# + location: + type: string + example: Rng + device_name: + type: string + example: Alpha II + responses: + 200: + description: user details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/User' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' + delete: + summary: delete user + description: 'Auth: basic, levels: read, write, maintain, admin' + tags: + - /user + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' +/user/{name}: + parameters: + - $ref: 'api.yaml#/components/parameters/Name' + get: + summary: list user details + description: 'Auth: basic, levels: admin' + tags: + - /user + security: + - BasicAuth: [] + responses: + 200: + description: user details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/User' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + put: + summary: change user details + description: 'Auth: basic, levels: admin' + tags: + - /user + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/User' + responses: + 200: + description: user details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/User' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + delete: + summary: delete user + description: 'Auth: basic, levels: admin' + tags: + - /user + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' +/user/key: + get: + summary: get API key for the user + description: 'Auth: basic, levels: read, write, maintain, dev, admin' + tags: + - /user + security: + - BasicAuth: [] + responses: + 200: + description: user details + content: + application/json: + schema: + properties: + key: + type: string + example: 5ea0450ed851c30a90e70899 + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' +/user/new: + post: + summary: add new user + description: 'Auth: basic, levels: admin' + tags: + - /user + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + required: + - email + - name + - pass + - level + - location + - device_name + allOf: + - $ref: 'api.yaml#/components/schemas/User' + responses: + 200: + description: user details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/User' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' +/user/passreset: + post: + summary: reset password and send mail to restore + description: 'Auth: none' + tags: + - /user + security: [] + requestBody: + required: true + description: mail saved in user profile to provide authentication + content: + application/json: + schema: + allOf: + - $ref: 'api.yaml#/components/schemas/UserName' + - $ref: 'api.yaml#/components/schemas/Email' + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..d632b14 --- /dev/null +++ b/build.bat @@ -0,0 +1,4 @@ +call npm run tsc-full +copy package.json dist\package.json +Xcopy /E /I api dist\api +Xcopy /E /I static dist\static \ No newline at end of file diff --git a/data_import/import.js b/data_import/import.js new file mode 100644 index 0000000..dc8c8d8 --- /dev/null +++ b/data_import/import.js @@ -0,0 +1,579 @@ +const csv = require('csv-parser'); +const fs = require('fs'); +const axios = require('axios'); +const {Builder} = require('selenium-webdriver'); // selenium and the chrome driver must be installed and configured separately +const chrome = require('selenium-webdriver/chrome'); +const pdfReader = require('pdfreader'); +const iconv = require('iconv-lite'); + +const metaDoc = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\metadata.csv'; // metadata files +const kfDoc = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\kf.csv'; +const vzDoc = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\vz.csv'; +const nmDocs = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\nmDocs'; // NormMaster Documents +const dptFiles = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\DPT'; // Spectrum files +const host = 'http://localhost:3000'; +// const host = 'https://definma-api.apps.de1.bosch-iot-cloud.com'; +let data = []; // metadata contents +let materials = {}; +let samples = []; +let normMaster = {}; +let sampleDevices = {}; + +// TODO: BASF twice, BASF as color +// TODO: duplicate kf values +// TODO: conditions +// TODO: comment and reference handling + + +// TODO: check last color errors (filter out already taken) use location and device for user, upload to BIC + +main(); + +async function main() { + if (0) { // materials + await getNormMaster(); + await importCsv(metaDoc); + await allMaterials(); + await saveMaterials(); + await importCsv(kfDoc); + await allMaterials(); + await saveMaterials(); + await importCsv(vzDoc); + await allMaterials(); + await saveMaterials(); + } + if (0) { // samples + sampleDeviceMap(); + if (1) { + console.log('-------- META ----------'); + await importCsv(metaDoc); + await allSamples(); + await saveSamples(); + } + if (1) { + console.log('-------- KF ----------'); + await importCsv(kfDoc); + await allSamples(); + await saveSamples(); + await allKfVz(); + } + if (1) { + console.log('-------- VZ ----------'); + await importCsv(vzDoc); + await allSamples(); + await saveSamples(); + await allKfVz(); + } + } + if (1) { // DPT + await allDpts(); + } + if (0) { // pdf test + console.log(await readPdf('N28_BN05-OX013_2016-03-11.pdf')); + } +} + +async function importCsv(doc) { + data = []; + await new Promise(resolve => { + fs.createReadStream(doc) + .pipe(iconv.decodeStream('win1252')) + .pipe(csv()) + .on('data', (row) => { + data.push(row); + }) + .on('end', () => { + console.info('CSV file successfully processed'); + if (data[0]['Farbe']) { // fix German column names + data.map(e => {e['Color'] = e['Farbe']; return e; }); + } + resolve(); + }); + }); +} + +async function allDpts() { + let res = await axios({ + method: 'get', + url: host + '/template/measurements', + auth: { + username: 'admin', + password: 'Abc123!#' + } + }); + const measurement_template = res.data.find(e => e.name === 'spectrum')._id; + res = await axios({ + method: 'get', + url: host + '/samples?status=all', + auth: { + username: 'admin', + password: 'Abc123!#' + } + }); + const sampleIds = {}; + res.data.forEach(sample => { + sampleIds[sample.number] = sample._id; + }); + const dptRegex = /.*?_(.*?)_(\d+|\d+_\d+).DPT/; + const dpts = fs.readdirSync(dptFiles); + for (let i in dpts) { + const regexRes = dptRegex.exec(dpts[i]) + if (regexRes && sampleIds[regexRes[1]]) { // found matching sample + console.log(dpts[i]); + const f = fs.readFileSync(dptFiles + '\\' + dpts[i], 'utf-8'); + const data = { + sample_id: sampleIds[regexRes[1]], + values: {}, + measurement_template + }; + data.values.dpt = f.split('\r\n').map(e => e.split(',')); + let rescale = false; + for (let i in data.values.dpt) { + if (data.values.dpt[i][1] > 2) { + rescale = true; + break; + } + } + if (rescale) { + data.values.dpt = data.values.dpt.map(e => [e[0], e[1] / 100]); + } + await axios({ + method: 'post', + url: host + '/measurement/new', + auth: { + username: 'admin', + password: 'Abc123!#' + }, + data + }).catch(err => { + console.log(dpts[i]); + console.error(err.response.data); + }); + } + else { + console.log(`Could not find sample for ${dpts[i]} !!!!!!`); + } + } +} + +async function allKfVz() { + let res = await axios({ + method: 'get', + url: host + '/template/measurements', + auth: { + username: 'admin', + password: 'Abc123!#' + } + }); + const kf_template = res.data.find(e => e.name === 'kf')._id; + const vz_template = res.data.find(e => e.name === 'vz')._id; + res = await axios({ + method: 'get', + url: host + '/samples?status=all', + auth: { + username: 'admin', + password: 'Abc123!#' + } + }); + const sampleIds = {}; + res.data.forEach(sample => { + sampleIds[sample.number] = sample._id; + }); + for (let index in data) { + console.info(`${index}/${data.length}`); + let sample = data[index]; + if (sample['Sample number'] !== '') { + let credentials = ['admin', 'Abc123!#']; + if (sampleDevices[sample['Sample number']]) { + credentials = [sampleDevices[sample['Sample number']], '2020DeFinMachen!'] + } + if (sample['KF in Gew%']) { + await axios({ + method: 'post', + url: host + '/measurement/new', + auth: { + username: credentials[0], + password: credentials[1] + }, + data: { + sample_id: sampleIds[sample['Sample number']], + measurement_template: kf_template, + values: { + 'weight %': sample['KF in Gew%'], + 'standard deviation': sample['Stabwn'] + } + } + }).catch(err => { + console.log(sample['Sample number']); + console.error(err.response.data); + }); + } + if (sample['VZ (ml/g)']) { + await axios({ + method: 'post', + url: host + '/measurement/new', + auth: { + username: credentials[0], + password: credentials[1] + }, + data: { + sample_id: sampleIds[sample['Sample number']], + measurement_template: vz_template, + values: { + vz: sample['VZ (ml/g)'] + } + } + }).catch(err => { + console.log(sample['Sample number']); + console.error(err.response.data); + }); + } + } + } +} + +async function allSamples() { + samples = []; + let res = await axios({ + method: 'get', + url: host + '/materials?status=all', + auth: { + username: 'admin', + password: 'Abc123!#' + } + }); + const dbMaterials = {} + res.data.forEach(m => { + dbMaterials[m.name] = m; + }) + res = await axios({ + method: 'get', + url: host + '/samples?status=all', + auth: { + username: 'admin', + password: 'Abc123!#' + } + }); + const sampleColors = {}; + res.data.forEach(sample => { + sampleColors[sample.number] = sample.color; + }); + + + for (let index in data) { + console.info(`${index}/${data.length}`); + let sample = data[index]; + if (sample['Sample number'] !== '') { // TODO: what about samples without color + if (sample['Supplier'] === '') { // empty supplier fields + sample['Supplier'] = 'unknown'; + } + if (sample['Granulate/Part'] === '') { // empty supplier fields + sample['Granulate/Part'] = 'unknown'; + } + const material = dbMaterials[trim(sample['Material name'])]; + if (!material) { // could not find material, skipping sample + continue; + } + console.log(sample['Material name']); + console.log(material._id); + samples.push({ + number: sample['Sample number'], + type: sample['Granulate/Part'], + batch: sample['Charge/batch granulate/part'] || '', + material_id: material._id, + notes: { + comment: sample['Comments'] + } + }); + const si = samples.length - 1; + if (sample['Material number'] !== '' && material.numbers.find(e => e.number === sample['Material number'])) { // TODO: fix because of false material/material number + samples[si].color = material.numbers.find(e => e.number === sample['Material number']).color; + } + else if (sample['Color'] && sample['Color'] !== '') { + let number = material.numbers.find(e => e.color.indexOf(trim(sample['Color'])) >= 0); + if (!number && /black/.test(sample['Color'])) { // special case bk for black + number = material.numbers.find(e => e.color.toLowerCase().indexOf('bk') >= 0); + if (!number) { // try German word + number = material.numbers.find(e => e.color.toLowerCase().indexOf('schwarz') >= 0); + } + } + samples[si].color = number.color; + } + else if (sampleColors[sample['Sample number'].split('_')[0]]) { // derive color from main sample for kf/vz + samples[si].color = sampleColors[sample['Sample number'].split('_')[0]]; + } + else { + samples[si].color = ''; + } + } + } +} + +async function saveSamples() { + for (let i in samples) { + console.info(`${i}/${samples.length}`); + let credentials = ['admin', 'Abc123!#']; + if (sampleDevices[samples[i].number]) { + credentials = [sampleDevices[samples[i].number], '2020DeFinMachen!'] + } + await axios({ + method: 'post', + url: host + '/sample/new', + auth: { + username: credentials[0], + password: credentials[1] + }, + data: samples[i] + }).catch(err => { + if (err.response.data.status && err.response.data.status !== 'Sample number already taken') { + console.log(samples[i]); + console.error(err.response.data); + } + }); + } + console.info('saved all samples'); +} + +async function allMaterials() { + materials = {}; + for (let index in data) { + let sample = data[index]; + if (sample['Sample number'] && sample['Sample number'] !== '') { + if (sample['Supplier'] === '') { // empty supplier fields + sample['Supplier'] = 'unknown'; + } + if (sample['Material name'] === '') { // empty name fields + sample['Material name'] = sample['Material']; + } + if (!sample['Material']) { // column Material is named Plastic in VZ metadata + sample['Material'] = sample['Plastic']; + } + sample['Material name'] = trim(sample['Material name']); + if (materials.hasOwnProperty(sample['Material name'])) { // material already found at least once + if (sample['Material number'] && sample['Material number'] !== '') { + if (materials[sample['Material name']].numbers.length === 0 || !materials[sample['Material name']].numbers.find(e => e.number === stripSpaces(sample['Material number']))) { // new material number + if (materials[sample['Material name']].numbers.find(e => e.color === sample['Color'] && e.number === '')) { // color already in list, only number missing + materials[sample['Material name']].numbers.find(e => e.color === sample['Color'] && e.number === '').number = stripSpaces(sample['Material number']); + } + else { + materials[sample['Material name']].numbers.push({color: trim(sample['Color']), number: stripSpaces(sample['Material number'])}); + } + } + } + else if (sample['Color'] && sample['Color'] !== '') { + if (!materials[sample['Material name']].numbers.find(e => e.color === stripSpaces(sample['Color']))) { // new material color + materials[sample['Material name']].numbers.push({color: trim(sample['Color']), number: ''}); + } + } + } + else { // new material + console.info(`${index}/${data.length} ${sample['Material name']}`); + materials[sample['Material name']] = { + name: sample['Material name'], + supplier: trim(sample['Supplier']), + group: trim(sample['Material']) + }; + let tmp = /M(\d+)/.exec(sample['Reinforcing material']); + materials[sample['Material name']].mineral = tmp ? tmp[1] : 0; + tmp = /GF(\d+)/.exec(sample['Reinforcing material']); + materials[sample['Material name']].glass_fiber = tmp ? tmp[1] : 0; + tmp = /CF(\d+)/.exec(sample['Reinforcing material']); + materials[sample['Material name']].carbon_fiber = tmp ? tmp[1] : 0; + materials[sample['Material name']].numbers = await numbersFetch(sample); + console.log(materials[sample['Material name']]); + } + } + } +} + +async function saveMaterials() { + const mKeys = Object.keys(materials) + for (let i in mKeys) { + console.info(`${i}/${mKeys.length}`); + await axios({ + method: 'post', + url: host + '/material/new', + auth: { + username: 'admin', + password: 'Abc123!#' + }, + data: materials[mKeys[i]] + }).catch(err => { + if (err.response.data.status && err.response.data.status !== 'Material name already taken') { + console.info(materials[mKeys[i]]); + console.error(err.response.data); + } + }); + } + console.info('saved all materials'); +} + +async function numbersFetch(sample) { + let nm = []; + let res = []; + if (sample['Material number']) { // sample has a material number + nm = normMaster[stripSpaces(sample['Material number'])]? [normMaster[stripSpaces(sample['Material number'])]] : []; + } + else { // try finding via material name + nm = Object.keys(normMaster).filter(e => normMaster[e].nameSpaceless === stripSpaces(sample['Material name'])).map(e => normMaster[e]); + } + if (nm.length > 0) { + for (let i in nm) { + // if (!fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0)) { // document not loaded + // await getNormMasterDoc(nm[i].url.replace(/ /g, '%20')); + // } + // if (!fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0)) { // document not loaded + // console.info('Retrying download...'); + // await getNormMasterDoc(nm[i].url.replace(/ /g, '%20'), 2.2); + // } + // if (!fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0)) { // document not loaded + // console.info('Retrying download again...'); + // await getNormMasterDoc(nm[i].url.replace(/ /g, '%20'), 5); + // } + if (fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0)) { // document loaded + res = await readPdf(fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0)); + } + if (res.length > 0) { // no results + break; + } + else if (i + 1 >= nm.length) { + console.error('Download failed!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); + } + } + } + if (res.length === 0) { // no results + if ((sample['Color'] && sample['Color'] !== '') || (sample['Material number'] &&sample['Material number'] !== '')) { + return [{color: trim(sample['Color']), number: sample['Material number']}]; + } + else { + return []; + } + } + else { + if (sample['Material number'] && !res.find(e => e.number === sample['Material number'])) { // sometimes norm master does not include sample number even if listed + res.push({color: trim(sample['Color']), number: sample['Material number']}); + } + return res; + } +} + +async function getNormMaster(fetchAgain = false) { + if (fetchAgain) { + console.info('fetching norm master...'); + const res = await axios({ + method: 'get', + url: 'http://rb-normen.bosch.com/cgi-bin/searchRBNorm4TradeName' + }); + + console.info('finding documents...'); + let match; + // const regex = /.*?
.*?<\/span>(.*?)<\/td>(\d+)<\/td>.*?.*?.*?<\/span>(.*?)<\/td>(\d+)<\/td>40.*?(.*?)<\/td>/gm; // only valid materials + do { + match = regex.exec(res.data); + if (match) { + normMaster[match[2]] = {name: match[1], nameSpaceless: stripSpaces(match[1]), number: match[2], url: match[3], doc: match[4]}; + } + } while (match); + fs.writeFileSync('./data_import/normMaster.json', JSON.stringify(normMaster)); + } + else { + normMaster = JSON.parse(fs.readFileSync('./data_import/normMaster.json'), 'utf-8'); + } +} + +function getNormMasterDoc(url, timing = 1) { + console.info(url); + return new Promise(async resolve => { + const options = new chrome.Options(); + options.setUserPreferences({ + "download.default_directory": nmDocs, + "download.prompt_for_download": false, + "download.directory_upgrade": true, + "plugins.always_open_pdf_externally": true + }); + let driver = await new Builder().forBrowser('chrome').setChromeOptions(options).build(); + let timeout = 7000 * timing; + try { + await driver.get(url); + if (await driver.getCurrentUrl() !== 'https://rb-wam-saml.bosch.com/tfim/sps/normmaster/saml20/login') { // got document selection page + timeout = 11000 * timing; + await driver.executeScript('Array.prototype.slice.call(document.querySelectorAll(\'.functionlink\')).filter(e => e.innerText === \'English\')[0].click()').catch(() => {timeout = 0; }); + } + } + finally { + setTimeout(async () => { // wait until download is finished + await driver.quit(); + resolve(); + }, timeout); + } + }); +} + +function readPdf(file) { + return new Promise(async resolve => { + const countdown = 100; // value for text timeout + let table = 0; // > 0 when in correct table area + let rows = []; // found table rows + let lastY = 0; // y of last row + let lastX = 0; // right x of last item + let lastText = ''; // text of last item + let lastLastText = ''; // text of last last item + await new pdfReader.PdfReader().parseFileItems(nmDocs + '\\' + file, (err, item) => { + if (item && item.text) { + if ((stripSpaces(lastLastText + lastText + item.text).toLowerCase().indexOf('colordesignationsupplier') >= 0) || (stripSpaces(lastLastText + lastText + item.text).toLowerCase().indexOf('colordesignatiomsupplier') >= 0)) { // table area starts + table = countdown; + } + if (table > 0) { + // console.log(item); + // console.log(item.y - lastY); + // console.log(item.text); + if (item.y - lastY > 0.8 && Math.abs(item.x - lastX) > 5) { // new row + lastY = item.y; + rows.push(item.text); + } + else { // still the same row row + rows[rows.length - 1] += (item.x - lastX > 1.09 ? '$' : '') + item.text; // push to row, detect if still same cell + } + lastX = (item.w * 0.055) + item.x; + + if (/\d \d\d\d \d\d\d \d\d\d/.test(item.text)) { + table = countdown; + } + table --; + if (table <= 0 || item.text.toLowerCase().indexOf('release document') >= 0 || item.text.toLowerCase().indexOf('normative references') >= 0) { // table area ended + table = -1; + // console.log(rows); + rows = rows.filter(e => /^\d{10}/m.test(stripSpaces(e))); // filter non-table rows + resolve(rows.map(e => {return {color: trim(e.split('$')[3]), number: stripSpaces(e.split('$')[0])}; })); + } + } + lastLastText = lastText; + lastText = item.text; + } + if (!item && table !== -1) { // document ended + rows = rows.filter(e => /^\d{10}/m.test(stripSpaces(e))); // filter non-table rows + resolve(rows.map(e => {return {color: trim(e.split('$')[3]), number: stripSpaces(e.split('$')[0])}; })); + } + }); + }); +} + +function sampleDeviceMap() { + const dpts = fs.readdirSync(dptFiles); + const regex = /(.*?)_(.*?)_(\d+|[^_]+_\d+).DPT/; + for (let i in dpts) { + const regexRes = regex.exec(dpts[i]) + if (regexRes) { // found matching sample + sampleDevices[regexRes[2]] = regexRes[1] === 'plastics' ? 'rng01' : regexRes[1].toLowerCase(); + } + } +} + +function stripSpaces(s) { + return s ? s.replace(/ /g,'') : ''; +} + +function trim(s) { + return s.replace(/(^\s+|\s+$)/gm, ''); +} \ No newline at end of file diff --git a/mainfest.yml b/manifest.yml similarity index 69% rename from mainfest.yml rename to manifest.yml index 16e5924..1791c58 100644 --- a/mainfest.yml +++ b/manifest.yml @@ -1,8 +1,9 @@ --- applications: - - name: digital-fingerprint-of-plastics-api + - name: definma-api + path: dist/ instances: 1 - memory: 256M + memory: 1024M stack: cflinuxfs3 buildpacks: - nodejs_buildpack @@ -10,4 +11,4 @@ applications: NODE_ENV: production OPTIMIZE_MEMORY: true services: - - dfopdb + - definmadb diff --git a/oas/condition.yaml b/oas/condition.yaml deleted file mode 100644 index 1259ec1..0000000 --- a/oas/condition.yaml +++ /dev/null @@ -1,69 +0,0 @@ -/condition/{id}: - parameters: - - $ref: 'oas.yaml#/components/parameters/Id' - get: - summary: TODO condition by id - description: 'levels: read, write, maintain, dev, admin' - tags: - - /condition - responses: - 200: - description: condition details - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Condition' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' - put: - summary: TODO add/change condition - description: 'levels: write, maintain, dev, admin' - tags: - - /condition - requestBody: - required: true - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Condition' - responses: - 200: - description: condition details - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Condition' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' - delete: - summary: TODO delete condition - description: 'levels: write, maintain, dev, admin' - tags: - - /condition - responses: - 200: - $ref: 'oas.yaml#/components/responses/Ok' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/material.yaml b/oas/material.yaml deleted file mode 100644 index 2ba26d7..0000000 --- a/oas/material.yaml +++ /dev/null @@ -1,63 +0,0 @@ -/material/{id}: - parameters: - - $ref: 'oas.yaml#/components/parameters/Id' - get: - summary: TODO get material details - description: 'levels: read, write, maintain, dev, admin' - tags: - - /material - responses: - 200: - description: created material - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Material' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 500: - $ref: 'oas.yaml#/components/responses/500' - put: - summary: TODO add/change material - description: 'levels: write, maintain, dev, admin' - tags: - - /material - requestBody: - required: true - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Material' - responses: - 200: - description: material details - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Material' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 500: - $ref: 'oas.yaml#/components/responses/500' - delete: - summary: TODO delete material - description: 'levels: write, maintain, dev, admin' - tags: - - /material - responses: - 200: - $ref: 'oas.yaml#/components/responses/Ok' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/measurement.yaml b/oas/measurement.yaml deleted file mode 100644 index 52c0430..0000000 --- a/oas/measurement.yaml +++ /dev/null @@ -1,69 +0,0 @@ -/measurement/{id}: - parameters: - - $ref: 'oas.yaml#/components/parameters/Id' - get: - summary: TODO measurement values by id - description: 'levels: read, write, maintain, dev, admin' - tags: - - /measurement - responses: - 200: - description: measurement details - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Measurement' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' - put: - summary: TODO add/change measurement - description: 'levels: write, maintain, dev, admin' - tags: - - /measurement - requestBody: - required: true - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Measurement' - responses: - 200: - description: measurement details - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Measurement' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' - delete: - summary: TODO delete measurement - description: 'levels: write, maintain, dev, admin' - tags: - - /measurement - responses: - 200: - $ref: 'oas.yaml#/components/responses/Ok' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/model.yaml b/oas/model.yaml deleted file mode 100644 index ce237e2..0000000 --- a/oas/model.yaml +++ /dev/null @@ -1,68 +0,0 @@ -/model/{name}: - parameters: - - $ref: 'oas.yaml#/components/parameters/Name' - get: - summary: TODO get model data by name - description: 'levels: dev, admin' - tags: - - /model - responses: - 200: - description: binary model data - content: - application/octet-stream: - schema: - type: string - format: binary - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' - put: - summary: TODO add/replace model data by name - description: 'levels: dev, admin' - tags: - - /model - requestBody: - required: true - description: binary model data - content: - application/json: - schema: - type: string - format: binary - responses: - 200: - $ref: 'oas.yaml#/components/responses/Ok' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' - delete: - summary: TODO delete model data - description: 'levels: dev, admin' - tags: - - /model - responses: - 200: - $ref: 'oas.yaml#/components/responses/Ok' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/others.yaml b/oas/others.yaml deleted file mode 100644 index df322fc..0000000 --- a/oas/others.yaml +++ /dev/null @@ -1,18 +0,0 @@ -/: - get: - summary: Root method - tags: - - / - security: [] - responses: - 200: - description: Server is working - content: - application/json: - schema: - properties: - message: - type: string - example: 'API server up and running!' - 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/parameters.yaml b/oas/parameters.yaml deleted file mode 100644 index 659808f..0000000 --- a/oas/parameters.yaml +++ /dev/null @@ -1,12 +0,0 @@ -Id: - name: id - in: path - required: true - schema: - type: string -Name: - name: name - in: path - required: true - schema: - type: string \ No newline at end of file diff --git a/oas/sample.yaml b/oas/sample.yaml deleted file mode 100644 index 8464e06..0000000 --- a/oas/sample.yaml +++ /dev/null @@ -1,108 +0,0 @@ -/samples: - get: - summary: TODO all samples in overview - description: 'levels: read, write, maintain, dev, admin' - tags: - - /sample - responses: - 200: - description: samples overview - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Samples' - 401: - $ref: 'oas.yaml#/components/responses/401' - 500: - $ref: 'oas.yaml#/components/responses/500' -/sample/{id}: - parameters: - - $ref: 'oas.yaml#/components/parameters/Id' - get: - summary: TODO sample details - description: 'levels: read, write, maintain, dev, admin' - tags: - - /sample - responses: - 200: - description: samples details - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/SampleDetail' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' - put: - summary: TODO add/change sample - description: 'levels: write, maintain, dev, admin' - tags: - - /sample - requestBody: - required: true - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Sample' - responses: - 200: - description: samples details - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/SampleDetail' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' - delete: - summary: TODO delete sample - description: 'levels: write, maintain, dev, admin' - tags: - - /sample - responses: - 200: - $ref: 'oas.yaml#/components/responses/Ok' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' -/sample/notes/fields: - get: - summary: TODO list all existing field names for custom notes fields - description: 'levels: write, maintain, dev, admin' - tags: - - /sample - responses: - 200: - description: field names and quantity of usage - content: - application/json: - schema: - properties: - name: - type: string - qty: - type: number - example: 20 - 401: - $ref: 'oas.yaml#/components/responses/401' - 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/schemas.yaml b/oas/schemas.yaml deleted file mode 100644 index 21ebae4..0000000 --- a/oas/schemas.yaml +++ /dev/null @@ -1,164 +0,0 @@ -Id: - type: string -_Id: - properties: - _id: - allOf: - - $ref: 'oas.yaml#/components/schemas/Id' - readOnly: true -Color: - properties: - color: - type: string -SampleProperties: - properties: - sample_number: - type: string - type: - type: string - batch: - type: string - validated: - type: boolean - -Samples: - allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' - - $ref: 'oas.yaml#/components/schemas/Color' - - $ref: 'oas.yaml#/components/schemas/SampleProperties' - properties: - material_id: - $ref: 'oas.yaml#/components/schemas/Id' - note_id: - $ref: 'oas.yaml#/components/schemas/Id' - user_id: - $ref: 'oas.yaml#/components/schemas/Id' -Sample: - allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' - - $ref: 'oas.yaml#/components/schemas/Color' - - $ref: 'oas.yaml#/components/schemas/SampleProperties' - properties: - material: - $ref: 'oas.yaml#/components/schemas/Material' - notes: - type: object - properties: - comments: - type: string - sample_references: - type: array - items: - $ref: 'oas.yaml#/components/schemas/Id' -SampleDetail: - allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' - - $ref: 'oas.yaml#/components/schemas/Color' - - $ref: 'oas.yaml#/components/schemas/SampleProperties' - properties: - material: - $ref: 'oas.yaml#/components/schemas/Material' - notes: - type: object - properties: - comments: - type: string - sample_references: - type: array - items: - $ref: 'oas.yaml#/components/schemas/Id' - conditions: - type: array - items: - $ref: 'oas.yaml#/components/schemas/Condition' - -Material: - allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' - properties: - material_numbers: - type: array - items: - type: object - allOf: - - $ref: 'oas.yaml#/components/schemas/Color' - properties: - number: - type: number - material_group: - type: string - supplier: - type: string - material_name: - type: string - mineral: - type: number - glass_fiber: - type: number - carbon_fiber: - type: number - -Condition: - allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' - properties: - sample_id: - $ref: 'oas.yaml#/components/schemas/Id' - parameters: - type: object - treatment_template: - $ref: 'oas.yaml#/components/schemas/Id' - -Measurement: - allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' - properties: - condition_id: - $ref: 'oas.yaml#/components/schemas/Id' - values: - type: object - measurement_template: - $ref: 'oas.yaml#/components/schemas/Id' - -Template: - allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' - properties: - name: - type: string - parameters: - type: array - items: - type: object - properties: - name: - type: string - range: - type: object - -Email: - required: - - email - properties: - email: - type: string - example: john.doe@bosch.com -User: - allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' - - $ref: 'oas.yaml#/components/schemas/Email' - properties: - name: - type: string - example: johndoe - levels: - type: array - items: - type: string - example: read - location: - type: string - example: Rng - device_name: - type: string - example: Alpha II \ No newline at end of file diff --git a/oas/template.yaml b/oas/template.yaml deleted file mode 100644 index bce58d0..0000000 --- a/oas/template.yaml +++ /dev/null @@ -1,242 +0,0 @@ -/template/treatments: - get: - summary: TODO all available treatment methods - description: 'levels: read, write, maintain, dev, admin' - tags: - - /templates - security: - - BasicAuth: [] - responses: - 200: - description: list of treatments - content: - application/json: - schema: - type: array - items: - $ref: 'oas.yaml#/components/schemas/Template' - example: - name: heat aging - parameters: - - name: method - range: - - copper - 401: - $ref: 'oas.yaml#/components/responses/401' - 500: - $ref: 'oas.yaml#/components/responses/500' -/templates/treatment/{name}: - parameters: - - $ref: 'oas.yaml#/components/parameters/Name' - get: - summary: TODO treatment method details - description: 'levels: read, write, maintain, admin' - tags: - - /templates - security: - - BasicAuth: [] - responses: - 200: - description: treatment details - content: - application/json: - schema: - allOf: - - $ref: 'oas.yaml#/components/schemas/Template' - example: - name: heat aging - parameters: - - name: method - range: - - copper - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' - put: - summary: TODO add/change treatment method - description: 'levels: maintain, admin' - tags: - - /templates - requestBody: - required: true - content: - application/json: - schema: - allOf: - - $ref: 'oas.yaml#/components/schemas/Template' - example: - name: heat aging - parameters: - - name: method - range: - - copper - responses: - 200: - description: treatment details - content: - application/json: - schema: - allOf: - - $ref: 'oas.yaml#/components/schemas/Template' - example: - name: heat aging - parameters: - - name: method - range: - - copper - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' - delete: - summary: TODO delete treatment method - description: 'levels: maintain, admin' - tags: - - /templates - responses: - 200: - $ref: 'oas.yaml#/components/responses/Ok' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' -/template/measurements: - get: - summary: TODO all available measurement methods - description: 'levels: read, write, maintain, dev, admin' - tags: - - /templates - security: - - BasicAuth: [] - responses: - 200: - description: list of measurement methods - content: - application/json: - schema: - type: array - items: - $ref: 'oas.yaml#/components/schemas/Template' - example: - name: humidity - parameters: - - name: kf - range: - min: 0 - max: 2 - 401: - $ref: 'oas.yaml#/components/responses/401' - 500: - $ref: 'oas.yaml#/components/responses/500' -/templates/measurement/{name}: - parameters: - - $ref: 'oas.yaml#/components/parameters/Name' - get: - summary: TODO measurement method details - description: 'levels: read, write, maintain, admin' - tags: - - /templates - security: - - BasicAuth: [] - responses: - 200: - description: measurement details - content: - application/json: - schema: - allOf: - - $ref: 'oas.yaml#/components/schemas/Template' - example: - name: humidity - parameters: - - name: kf - range: - min: 0 - max: 2 - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' - put: - summary: TODO add/change measurement method - description: 'levels: maintain, admin' - tags: - - /templates - requestBody: - required: true - content: - application/json: - schema: - allOf: - - $ref: 'oas.yaml#/components/schemas/Template' - example: - name: humidity - parameters: - - name: kf - range: - min: 0 - max: 2 - responses: - 200: - description: measurement details - content: - application/json: - schema: - allOf: - - $ref: 'oas.yaml#/components/schemas/Template' - example: - name: humidity - parameters: - - name: kf - range: - min: 0 - max: 2 - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' - delete: - summary: TODO delete measurement method - description: 'levels: maintain, admin' - tags: - - /templates - responses: - 200: - $ref: 'oas.yaml#/components/responses/Ok' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/user.yaml b/oas/user.yaml deleted file mode 100644 index c9f10b1..0000000 --- a/oas/user.yaml +++ /dev/null @@ -1,170 +0,0 @@ -/users: - get: - summary: TODO lists all users - description: 'levels: admin' - tags: - - /user - security: - - BasicAuth: [] - responses: - 200: - description: user API key - content: - application/json: - schema: - type: array - items: - $ref: 'oas.yaml#/components/schemas/User' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 500: - $ref: 'oas.yaml#/components/responses/500' -/user/{name}: - parameters: - - $ref: 'oas.yaml#/components/parameters/Name' - get: - summary: TODO list user details - description: 'levels: read, write, maintain, dev get their own information without a name property specified, level: admin can get any user using the name parameter' - tags: - - /user - security: - - BasicAuth: [] - responses: - 200: - description: user details - content: - application/json: - schema: - type: array - items: - $ref: 'oas.yaml#/components/schemas/User' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' - put: - summary: TODO change user details - description: 'levels: read, write, maintain, dev can change their own information (except level) without a name property specified, level: admin can change any user using the name parameter' - tags: - - /user - requestBody: - required: true - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/User' - responses: - 200: - description: user details - content: - application/json: - schema: - type: array - items: - $ref: 'oas.yaml#/components/schemas/User' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' - delete: - summary: TODO delete user - description: 'levels: read, write, maintain, dev can delete their own account, level: admin can delete any user using the name parameter' - tags: - - /user - responses: - 200: - $ref: 'oas.yaml#/components/responses/Ok' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' - 500: - $ref: 'oas.yaml#/components/responses/500' -/user/key: - get: - summary: TODO get API key for the user - description: 'levels: read, write, maintain, dev, admin' - tags: - - /user - security: - - BasicAuth: [] - responses: - 200: - description: user details - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/User' - 401: - $ref: 'oas.yaml#/components/responses/401' - 500: - $ref: 'oas.yaml#/components/responses/500' -/user/new: - post: - summary: TODO add new user - description: 'levels: admin' - tags: - - /user - security: - - BasicAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/User' - responses: - 200: - description: user details - content: - application/json: - schema: - type: array - items: - $ref: 'oas.yaml#/components/schemas/User' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 500: - $ref: 'oas.yaml#/components/responses/500' -/user/passreset: - post: - summary: TODO reset password and send mail to restore - tags: - - /user - security: [] - requestBody: - required: true - description: mail saved in user profile to provide authentication - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Email' - responses: - 200: - $ref: 'oas.yaml#/components/responses/Ok' - 401: - $ref: 'oas.yaml#/components/responses/401' - 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ae887b0..34fb53e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,24 +14,379 @@ "js-yaml": "^3.13.1" } }, + "@apidevtools/openapi-schemas": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.0.3.tgz", + "integrity": "sha512-QoPaxGXfgqgGpK1p21FJ400z56hV681a8DOcZt3J5z0WIHgFeaIZ4+6bX5ATqmOoCpRCsH4ITEwKaOyFMz7wOA==" + }, + "@apidevtools/swagger-methods": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.1.tgz", + "integrity": "sha512-1Vlm18XYW6Yg7uHunroXeunWz5FShPFAdxBbPy8H6niB2Elz9QQsCoYHMbcc11EL1pTxaIr9HXz2An/mHXlX1Q==" + }, + "@apidevtools/swagger-parser": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-9.0.1.tgz", + "integrity": "sha512-Irqybg4dQrcHhZcxJc/UM4vO7Ksoj1Id5e+K94XUOzllqX1n47HEA50EKiXTCQbykxuJ4cYGIivjx/MRSTC5OA==", + "requires": { + "@apidevtools/json-schema-ref-parser": "^8.0.0", + "@apidevtools/openapi-schemas": "^2.0.2", + "@apidevtools/swagger-methods": "^3.0.0", + "@jsdevtools/ono": "^7.1.0", + "call-me-maybe": "^1.0.1", + "openapi-types": "^1.3.5", + "z-schema": "^4.2.2" + } + }, "@babel/code-frame": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, "requires": { "@babel/highlight": "^7.8.3" } }, + "@babel/core": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.9.6.tgz", + "integrity": "sha512-nD3deLvbsApbHAHttzIssYqgb883yU/d9roe4RZymBCDaZryMJDbptVpEpeQuRh4BJ+SYI8le9YGxKvFEvl1Wg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.6", + "@babel/helper-module-transforms": "^7.9.0", + "@babel/helpers": "^7.9.6", + "@babel/parser": "^7.9.6", + "@babel/template": "^7.8.6", + "@babel/traverse": "^7.9.6", + "@babel/types": "^7.9.6", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.13", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.9.6.tgz", + "integrity": "sha512-+htwWKJbH2bL72HRluF8zumBxzuX0ZZUFl3JLNyoUjM/Ho8wnVpPXM6aUz8cfKDqQ/h7zHqKt4xzJteUosckqQ==", + "dev": true, + "requires": { + "@babel/types": "^7.9.6", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.9.5.tgz", + "integrity": "sha512-JVcQZeXM59Cd1qanDUxv9fgJpt3NeKUaqBqUEvfmQ+BCOKq2xUgaWZW2hr0dkbyJgezYuplEoh5knmrnS68efw==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.9.5" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz", + "integrity": "sha512-fO4Egq88utkQFjbPrSHGmGLFqmrshs11d46WI+WZDESt7Wu7wN2G2Iu+NMMZJFDOVRHAMIkB5SNh30NtwCA7RA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-module-imports": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.8.3.tgz", + "integrity": "sha512-R0Bx3jippsbAEtzkpZ/6FIiuzOURPcMjHp+Z6xPe6DtApDJx+w7UYyOLanZqO8+wKR9G10s/FmHXvxaMd9s6Kg==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-module-transforms": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.9.0.tgz", + "integrity": "sha512-0FvKyu0gpPfIQ8EkxlrAydOWROdHpBmiCiRwLkUiBGhCUPRRbVD2/tm3sFr/c/GWFrQ/ffutGUAnx7V0FzT2wA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.8.3", + "@babel/helper-replace-supers": "^7.8.6", + "@babel/helper-simple-access": "^7.8.3", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/template": "^7.8.6", + "@babel/types": "^7.9.0", + "lodash": "^4.17.13" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz", + "integrity": "sha512-Kag20n86cbO2AvHca6EJsvqAd82gc6VMGule4HwebwMlwkpXuVqrNRj6CkCV2sKxgi9MyAUnZVnZ6lJ1/vKhHQ==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-replace-supers": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.9.6.tgz", + "integrity": "sha512-qX+chbxkbArLyCImk3bWV+jB5gTNU/rsze+JlcF6Nf8tVTigPJSI1o1oBow/9Resa1yehUO9lIipsmu9oG4RzA==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.8.3", + "@babel/helper-optimise-call-expression": "^7.8.3", + "@babel/traverse": "^7.9.6", + "@babel/types": "^7.9.6" + } + }, + "@babel/helper-simple-access": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.8.3.tgz", + "integrity": "sha512-VNGUDjx5cCWg4vvCTR8qQ7YJYZ+HBjxOgXEl7ounz+4Sn7+LMD3CFrCTEU6/qXKbA2nKg21CwhhBzO0RpRbdCw==", + "dev": true, + "requires": { + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", + "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.5.tgz", + "integrity": "sha512-/8arLKUFq882w4tWGj9JYzRpAlZgiWUJ+dtteNTDqrRBz9Iguck9Rn3ykuBDoUwh2TO4tSAJlrxDUOXWklJe4g==", + "dev": true + }, + "@babel/helpers": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.9.6.tgz", + "integrity": "sha512-tI4bUbldloLcHWoRUMAj4g1bF313M/o6fBKhIsb3QnGVPwRm9JsNf/gqMkQ7zjqReABiffPV6RWj7hEglID5Iw==", + "dev": true, + "requires": { + "@babel/template": "^7.8.3", + "@babel/traverse": "^7.9.6", + "@babel/types": "^7.9.6" + } + }, "@babel/highlight": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.8.3.tgz", "integrity": "sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg==", + "dev": true, "requires": { "chalk": "^2.0.0", "esutils": "^2.0.2", "js-tokens": "^4.0.0" } }, + "@babel/parser": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.6.tgz", + "integrity": "sha512-AoeIEJn8vt+d/6+PXDRPaksYhnlbMIiejioBZvvMQsOjW/JYK6k/0dKnvvP3EhK5GfMBWDPtrxRtegWdAcdq9Q==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/traverse": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.6.tgz", + "integrity": "sha512-b3rAHSjbxy6VEAvlxM8OV/0X4XrG72zoxme6q1MOoe2vd0bEc+TwayhuC1+Dfgqh1QEG+pj7atQqvUprHIccsg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.6", + "@babel/helper-function-name": "^7.9.5", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/parser": "^7.9.6", + "@babel/types": "^7.9.6", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@babel/types": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.6.tgz", + "integrity": "sha512-qxXzvBO//jO9ZnoasKF1uJzHd2+M6Q2ZPIVfnFps8JJvXy0ZBbwbNOmE6SGIY5XOY6d1Bo5lb9d9RJ8nv3WSeA==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.5", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "@hapi/address": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-4.0.1.tgz", + "integrity": "sha512-0oEP5UiyV4f3d6cBL8F3Z5S7iWSX39Knnl0lY8i+6gfmmIBj44JCBNtcMgwyS+5v7j3VYavNay0NFHDS+UGQcw==", + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, + "@hapi/formula": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-2.0.0.tgz", + "integrity": "sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A==" + }, + "@hapi/hoek": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.0.4.tgz", + "integrity": "sha512-EwaJS7RjoXUZ2cXXKZZxZqieGtc7RbvQhUy8FwDoMQtxWVi14tFjeFCYPZAM1mBCpOpiBpyaZbb9NeHc7eGKgw==" + }, + "@hapi/joi": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-17.1.1.tgz", + "integrity": "sha512-p4DKeZAoeZW4g3u7ZeRo+vCDuSDgSvtsB/NpfjXEHTUjSeINAi/RrVOWiVQ1isaoLzMvFEhe8n5065mQq1AdQg==", + "requires": { + "@hapi/address": "^4.0.1", + "@hapi/formula": "^2.0.0", + "@hapi/hoek": "^9.0.0", + "@hapi/pinpoint": "^2.0.0", + "@hapi/topo": "^5.0.0" + } + }, + "@hapi/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw==" + }, + "@hapi/topo": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.0.0.tgz", + "integrity": "sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw==", + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, + "@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@istanbuljs/schema": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz", + "integrity": "sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==", + "dev": true + }, "@jsdevtools/ono": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.2.tgz", @@ -40,35 +395,139 @@ "@sindresorhus/is": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", - "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==" + "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", + "dev": true }, "@szmarczak/http-timer": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", + "dev": true, "requires": { "defer-to-connect": "^1.0.1" } }, + "@types/bcrypt": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-3.0.0.tgz", + "integrity": "sha512-nohgNyv+1ViVcubKBh0+XiNJ3dO8nYu///9aJ4cgSqv70gBL+94SNy/iC2NLzKPT2Zt/QavrOkBVbZRLZmw6NQ==", + "dev": true + }, + "@types/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/bson": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.2.tgz", + "integrity": "sha512-+uWmsejEHfmSjyyM/LkrP0orfE2m5Mx9Xel4tXNeqi1ldK5XMQcDsFkBmLDtuyKUbxj2jGDo0H240fbCRJZo7Q==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", - "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" + "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", + "dev": true + }, + "@types/connect": { + "version": "3.4.33", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.33.tgz", + "integrity": "sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.5", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.5.tgz", + "integrity": "sha512-578YH5Lt88AKoADy0b2jQGwJtrBxezXtVe/MBqWXKZpqx91SnC0pVkVCcxcytz3lWW+cHBYDi3Ysh0WXc+rAYw==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/range-parser": "*" + } + }, + "@types/lodash": { + "version": "4.14.150", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.150.tgz", + "integrity": "sha512-kMNLM5JBcasgYscD9x/Gvr6lTAv2NVgsKtet/hm93qMyf/D1pt+7jeEZklKJKxMVmXjxbRVQQGfqDSfipYCO6w==", + "dev": true + }, + "@types/mime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", + "integrity": "sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==", + "dev": true }, "@types/mocha": { "version": "5.2.7", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", - "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==" + "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==", + "dev": true + }, + "@types/mongodb": { + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.5.10.tgz", + "integrity": "sha512-6NkJNfFdFa/njBvN/9eAfq78bWUnapkdR3JbWGGpd7U71PjgKweA4Tlag8psi2mqm973vBYVTD1oc1u0lzRcig==", + "dev": true, + "requires": { + "@types/bson": "*", + "@types/node": "*" + } + }, + "@types/mongoose": { + "version": "5.7.12", + "resolved": "https://registry.npmjs.org/@types/mongoose/-/mongoose-5.7.12.tgz", + "integrity": "sha512-yzLJk3cdSwuMXaIacUCWUb8m960YcgnID7S4ZPOOgzT39aSC46670TuunN+ajDio7OUcGG4mGg8eOGs2Z6VmrA==", + "dev": true, + "requires": { + "@types/mongodb": "*", + "@types/node": "*" + } }, "@types/node": { "version": "13.1.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-13.1.6.tgz", - "integrity": "sha512-Jg1F+bmxcpENHP23sVKkNuU3uaxPnsBMW0cLjleiikFKomJQbsn0Cqk2yDvQArqzZN6ABfBkZ0To7pQ8sLdWDg==" + "integrity": "sha512-Jg1F+bmxcpENHP23sVKkNuU3uaxPnsBMW0cLjleiikFKomJQbsn0Cqk2yDvQArqzZN6ABfBkZ0To7pQ8sLdWDg==", + "dev": true + }, + "@types/qs": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.1.tgz", + "integrity": "sha512-lhbQXx9HKZAPgBkISrBcmAcMpZsmpe/Cd/hY7LGZS5OfkySUBItnPZHgQPssWYUET8elF+yCFBbP1Q0RZPTdaw==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==", + "dev": true + }, + "@types/serve-static": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.3.tgz", + "integrity": "sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g==", + "dev": true, + "requires": { + "@types/express-serve-static-core": "*", + "@types/mime": "*" + } }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true }, "accepts": { "version": "1.3.7", @@ -79,10 +538,21 @@ "negotiator": "0.6.2" } }, + "aggregate-error": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz", + "integrity": "sha512-quoaXsZ9/BLNae5yiNoUz+Nhkwz83GhWwtYFglcjEQB2NDHCIpApbqXxIFnm4Pq/Nvhrsq5sYJFyohrrxnTGAA==", + "dev": true, + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + } + }, "ansi-align": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz", "integrity": "sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw==", + "dev": true, "requires": { "string-width": "^3.0.0" }, @@ -90,12 +560,14 @@ "ansi-regex": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true }, "string-width": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, "requires": { "emoji-regex": "^7.0.1", "is-fullwidth-code-point": "^2.0.0", @@ -106,6 +578,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, "requires": { "ansi-regex": "^4.1.0" } @@ -128,6 +601,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, "requires": { "color-convert": "^1.9.0" } @@ -136,11 +610,27 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==", + "dev": true, "requires": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, + "append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "requires": { + "default-require-extensions": "^3.0.0" + } + }, + "archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", + "dev": true + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -160,15 +650,38 @@ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", "dev": true }, + "axios": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "requires": { + "follow-redirects": "1.5.10" + } + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "requires": { + "safe-buffer": "5.1.2" + } + }, + "bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" }, "binary-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz", - "integrity": "sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==" + "integrity": "sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==", + "dev": true }, "bluebird": { "version": "3.5.1", @@ -190,12 +703,28 @@ "qs": "6.7.0", "raw-body": "2.4.0", "type-is": "~1.6.17" + }, + "dependencies": { + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + } } }, + "bowser": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.9.0.tgz", + "integrity": "sha512-2ld76tuLBNFekRgmJfT2+3j5MIrP6bFict8WAIT3beq+srz1gcKNAdNKMqHqauQt63NmAa88HfP1/Ypa9Er3HA==" + }, "boxen": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/boxen/-/boxen-4.2.0.tgz", "integrity": "sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ==", + "dev": true, "requires": { "ansi-align": "^3.0.0", "camelcase": "^5.3.1", @@ -210,12 +739,14 @@ "ansi-regex": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true }, "ansi-styles": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, "requires": { "@types/color-name": "^1.1.1", "color-convert": "^2.0.1" @@ -225,6 +756,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -234,6 +766,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "requires": { "color-name": "~1.1.4" } @@ -241,27 +774,32 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true }, "string-width": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -272,6 +810,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, "requires": { "ansi-regex": "^5.0.0" } @@ -280,6 +819,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -290,6 +830,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -299,6 +840,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, "requires": { "fill-range": "^7.0.1" } @@ -317,7 +859,8 @@ "builtin-modules": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", - "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=" + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", + "dev": true }, "bytes": { "version": "3.1.0", @@ -328,6 +871,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", + "dev": true, "requires": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", @@ -342,6 +886,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz", "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==", + "dev": true, "requires": { "pump": "^3.0.0" } @@ -349,10 +894,23 @@ "lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true } } }, + "caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "requires": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + } + }, "call-me-maybe": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", @@ -361,7 +919,13 @@ "camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "camelize": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.0.tgz", + "integrity": "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=" }, "cfenv": { "version": "1.2.2", @@ -377,6 +941,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -387,6 +952,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.3.1.tgz", "integrity": "sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg==", + "dev": true, "requires": { "anymatch": "~3.1.1", "braces": "~3.0.2", @@ -401,12 +967,20 @@ "ci-info": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==" + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true }, "cli-boxes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.0.tgz", - "integrity": "sha512-gpaBrMAizVEANOpfZp/EEUixTXDyGt7DFzdK5hU+UbWt/J0lB0w20ncZj59Z9a93xHb9u12zF5BS6i9RKbtg4w==" + "integrity": "sha512-gpaBrMAizVEANOpfZp/EEUixTXDyGt7DFzdK5hU+UbWt/J0lB0w20ncZj59Z9a93xHb9u12zF5BS6i9RKbtg4w==", + "dev": true }, "cliui": { "version": "5.0.0", @@ -451,6 +1025,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", + "dev": true, "requires": { "mimic-response": "^1.0.0" } @@ -459,6 +1034,7 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, "requires": { "color-name": "1.1.3" } @@ -466,7 +1042,8 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true }, "combined-stream": { "version": "1.0.8", @@ -482,21 +1059,58 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", + "dev": true + }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", "dev": true }, + "compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "requires": { + "mime-db": ">= 1.43.0 < 2" + } + }, + "compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + } + } + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true }, "configstore": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", + "dev": true, "requires": { "dot-prop": "^5.2.0", "graceful-fs": "^4.1.2", @@ -514,11 +1128,30 @@ "safe-buffer": "5.1.2" } }, + "content-filter": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/content-filter/-/content-filter-1.1.2.tgz", + "integrity": "sha512-VaZ4Y7h776r0v2WxWqu3iatjYI6/N0msXK8O1ymtkFWbSvaFoCePksS8U60BS6dUMZeAlqhN09SuM7ghdzRP1Q==" + }, + "content-security-policy-builder": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-security-policy-builder/-/content-security-policy-builder-2.1.0.tgz", + "integrity": "sha512-/MtLWhJVvJNkA9dVLAp6fg9LxD2gfI6R2Fi1hPmfjYXSahJJzcfvoeDOxSyp4NvxMuwWv3WMssE9o31DoULHrQ==" + }, "content-type": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" }, + "convert-source-map": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, "cookie": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", @@ -541,10 +1174,57 @@ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "dependencies": { + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "crypto-random-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", - "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==" + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true + }, + "csv-parser": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-2.3.3.tgz", + "integrity": "sha512-czcyxc4/3Tt63w0oiK1zsnRgRD4PkqWaRSJ6eef63xC0f+5LVLuGdSYEcJwGp2euPgRHx+jmlH2Lb49anb1CGQ==", + "dev": true, + "requires": { + "minimist": "^1.2.0", + "through2": "^3.0.1" + } + }, + "dasherize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dasherize/-/dasherize-2.0.0.tgz", + "integrity": "sha1-bYCcnNDPe7iVLYD8hPoT1H3bEwg=" }, "debug": { "version": "2.6.9", @@ -564,6 +1244,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", + "dev": true, "requires": { "mimic-response": "^1.0.0" } @@ -571,12 +1252,23 @@ "deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true + }, + "default-require-extensions": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.0.tgz", + "integrity": "sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==", + "dev": true, + "requires": { + "strip-bom": "^4.0.0" + } }, "defer-to-connect": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", - "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==" + "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", + "dev": true }, "define-properties": { "version": "1.1.3", @@ -606,12 +1298,24 @@ "diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==" + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + }, + "dns-prefetch-control": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dns-prefetch-control/-/dns-prefetch-control-0.2.0.tgz", + "integrity": "sha512-hvSnros73+qyZXhHFjx2CMLwoj3Fe7eR9EJsFsqmcI1bB2OBWL/+0YzaEaKssCHnj/6crawNnUyw74Gm2EKe+Q==" + }, + "dont-sniff-mimetype": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/dont-sniff-mimetype/-/dont-sniff-mimetype-1.1.0.tgz", + "integrity": "sha512-ZjI4zqTaxveH2/tTlzS1wFp+7ncxNZaIEWYg3lzZRHkKf5zPT/MnEG6WL0BhHMJUabkh8GeU5NL5j+rEUCb7Ug==" }, "dot-prop": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz", "integrity": "sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==", + "dev": true, "requires": { "is-obj": "^2.0.0" } @@ -619,7 +1323,8 @@ "duplexer3": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", - "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=" + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", + "dev": true }, "ee-first": { "version": "1.1.1", @@ -629,7 +1334,8 @@ "emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true }, "encodeurl": { "version": "1.0.2", @@ -640,14 +1346,15 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, "requires": { "once": "^1.4.0" } }, "es-abstract": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.0.tgz", - "integrity": "sha512-yYkE07YF+6SIBmg1MsJ9dlub5L48Ek7X0qz+c/CPCHS9EBXfESorzng4cJQjJW5/pB6vDF41u7F8vUhLVDqIug==", + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz", + "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==", "dev": true, "requires": { "es-to-primitive": "^1.2.1", @@ -674,10 +1381,17 @@ "is-symbol": "^1.0.2" } }, + "es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true + }, "escape-goat": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", - "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==" + "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==", + "dev": true }, "escape-html": { "version": "1.0.3", @@ -687,7 +1401,8 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true }, "esprima": { "version": "4.0.1", @@ -697,13 +1412,19 @@ "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true }, "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, + "expect-ct": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/expect-ct/-/expect-ct-0.2.0.tgz", + "integrity": "sha512-6SK3MG/Bbhm8MsgyJAylg+ucIOU71/FzyFalcfu5nY19dH8y/z0tBJU0wrNBXD4B27EoQtqPF/9wqH0iYAd04g==" + }, "express": { "version": "4.17.1", "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", @@ -747,10 +1468,16 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "dev": true }, + "feature-policy": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/feature-policy/-/feature-policy-0.3.0.tgz", + "integrity": "sha512-ZtijOTFN7TzCujt1fnNhfWPFPSHeZkesff9AXZj+UEjYBynWNUIYpC87Ve4wHzyexQsImicLu7WsC2LHq7/xrQ==" + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, "requires": { "to-regex-range": "^5.0.1" } @@ -769,6 +1496,17 @@ "unpipe": "~1.0.0" } }, + "find-cache-dir": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.1.tgz", + "integrity": "sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, "find-up": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", @@ -787,6 +1525,34 @@ "is-buffer": "~2.0.3" } }, + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "requires": { + "debug": "=3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + } + }, "form-data": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", @@ -809,20 +1575,33 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" }, + "frameguard": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/frameguard/-/frameguard-3.1.0.tgz", + "integrity": "sha512-TxgSKM+7LTA6sidjOiSZK9wxY0ffMPY3Wta//MqwmX0nZuEHc8QrkV8Fh3ZhMJeiH+Uyh/tcaarImRy8u77O7g==" + }, "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" }, + "fromentries": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.2.0.tgz", + "integrity": "sha512-33X7H/wdfO99GdRLLgkjUrD4geAFdq/Uv0kl3HD4da6HDixd2GUg8Mw7dahLCV9r/EARkmtYBB6Tch4EEokFTQ==", + "dev": true + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true }, "fsevents": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.2.tgz", "integrity": "sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA==", + "dev": true, "optional": true }, "function-bind": { @@ -831,16 +1610,29 @@ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "dev": true }, + "gensync": { + "version": "1.0.0-beta.1", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz", + "integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==", + "dev": true + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true }, + "get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true + }, "get-stream": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, "requires": { "pump": "^3.0.0" } @@ -849,6 +1641,7 @@ "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -862,6 +1655,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.0.tgz", "integrity": "sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==", + "dev": true, "requires": { "is-glob": "^4.0.1" } @@ -870,14 +1664,22 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-2.0.1.tgz", "integrity": "sha512-5HqUqdhkEovj2Of/ms3IeS/EekcO54ytHRLV4PEY2rhRwrHXLQjeVEES0Lhka0xwNDtGYn58wyC4s5+MHsOO6A==", + "dev": true, "requires": { "ini": "^1.3.5" } }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, "got": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", + "dev": true, "requires": { "@sindresorhus/is": "^0.14.0", "@szmarczak/http-timer": "^1.1.2", @@ -895,7 +1697,8 @@ "graceful-fs": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", - "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==" + "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", + "dev": true }, "growl": { "version": "1.10.5", @@ -915,7 +1718,8 @@ "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true }, "has-symbols": { "version": "1.0.1", @@ -926,7 +1730,18 @@ "has-yarn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz", - "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==" + "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==", + "dev": true + }, + "hasha": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.0.tgz", + "integrity": "sha512-2W+jKdQbAdSIrggA8Q35Br8qKadTrqCTC8+XZvBWepKDK6m9XkX6Iz1a2yh2KP01kzAR/dpuMeUnocoLYDcskw==", + "dev": true, + "requires": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + } }, "he": { "version": "1.2.0", @@ -934,10 +1749,87 @@ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true }, + "helmet": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-3.22.0.tgz", + "integrity": "sha512-Xrqicn2nm1ZIUxP3YGuTBmbDL04neKsIT583Sjh0FkiwKDXYCMUqGqC88w3NUvVXtA75JyR2Jn6jw6ZEMOD+ZA==", + "requires": { + "depd": "2.0.0", + "dns-prefetch-control": "0.2.0", + "dont-sniff-mimetype": "1.1.0", + "expect-ct": "0.2.0", + "feature-policy": "0.3.0", + "frameguard": "3.1.0", + "helmet-crossdomain": "0.4.0", + "helmet-csp": "2.10.0", + "hide-powered-by": "1.1.0", + "hpkp": "2.0.0", + "hsts": "2.2.0", + "ienoopen": "1.1.0", + "nocache": "2.1.0", + "referrer-policy": "1.2.0", + "x-xss-protection": "1.3.0" + }, + "dependencies": { + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + } + } + }, + "helmet-crossdomain": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/helmet-crossdomain/-/helmet-crossdomain-0.4.0.tgz", + "integrity": "sha512-AB4DTykRw3HCOxovD1nPR16hllrVImeFp5VBV9/twj66lJ2nU75DP8FPL0/Jp4jj79JhTfG+pFI2MD02kWJ+fA==" + }, + "helmet-csp": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/helmet-csp/-/helmet-csp-2.10.0.tgz", + "integrity": "sha512-Rz953ZNEFk8sT2XvewXkYN0Ho4GEZdjAZy4stjiEQV3eN7GDxg1QKmYggH7otDyIA7uGA6XnUMVSgeJwbR5X+w==", + "requires": { + "bowser": "2.9.0", + "camelize": "1.0.0", + "content-security-policy-builder": "2.1.0", + "dasherize": "2.0.0" + } + }, + "hide-powered-by": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hide-powered-by/-/hide-powered-by-1.1.0.tgz", + "integrity": "sha512-Io1zA2yOA1YJslkr+AJlWSf2yWFkKjvkcL9Ni1XSUqnGLr/qRQe2UI3Cn/J9MsJht7yEVCe0SscY1HgVMujbgg==" + }, + "hpkp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hpkp/-/hpkp-2.0.0.tgz", + "integrity": "sha1-EOFCJk52IVpdMMROxD3mTe5tFnI=" + }, + "hsts": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hsts/-/hsts-2.2.0.tgz", + "integrity": "sha512-ToaTnQ2TbJkochoVcdXYm4HOCliNozlviNsg+X2XQLQvZNI/kCHR9rZxVYpJB3UPcHz80PgxRyWQ7PdU1r+VBQ==", + "requires": { + "depd": "2.0.0" + }, + "dependencies": { + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + } + } + }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", - "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" + "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", + "dev": true }, "http-errors": { "version": "1.7.2", @@ -952,32 +1844,54 @@ } }, "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.0.tgz", + "integrity": "sha512-43ZpGYZ9QtuutX5l6WC1DSO8ane9N+Ct5qPLF2OV7vM9abM69gnAbVkh66ibaZd3aOGkoP1ZmringlKhLBkw2Q==", + "dev": true, "requires": { "safer-buffer": ">= 2.1.2 < 3" } }, + "ienoopen": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ienoopen/-/ienoopen-1.1.0.tgz", + "integrity": "sha512-MFs36e/ca6ohEKtinTJ5VvAJ6oDRAYFdYXweUnGY9L9vcoqFOU4n2ZhmJ0C4z/cwGZ3YIQRSB3XZ1+ghZkY5NQ==" + }, "ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha1-SMptcvbGo68Aqa1K5odr44ieKwk=" + "integrity": "sha1-SMptcvbGo68Aqa1K5odr44ieKwk=", + "dev": true + }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=", + "dev": true }, "import-lazy": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", - "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=" + "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=", + "dev": true }, "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -991,7 +1905,8 @@ "ini": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", - "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "dev": true }, "ipaddr.js": { "version": "1.9.0", @@ -1002,6 +1917,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "requires": { "binary-extensions": "^2.0.0" } @@ -1022,6 +1938,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, "requires": { "ci-info": "^2.0.0" } @@ -1035,17 +1952,20 @@ "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true }, "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true }, "is-glob": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, "requires": { "is-extglob": "^2.1.1" } @@ -1054,6 +1974,7 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz", "integrity": "sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g==", + "dev": true, "requires": { "global-dirs": "^2.0.1", "is-path-inside": "^3.0.1" @@ -1062,22 +1983,26 @@ "is-npm": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz", - "integrity": "sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig==" + "integrity": "sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig==", + "dev": true }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true }, "is-obj": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true }, "is-path-inside": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz", - "integrity": "sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==" + "integrity": "sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==", + "dev": true }, "is-regex": { "version": "1.0.5", @@ -1088,6 +2013,12 @@ "has": "^1.0.3" } }, + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "dev": true + }, "is-symbol": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", @@ -1100,12 +2031,20 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true }, "is-yarn-global": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz", - "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==" + "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==", + "dev": true }, "isarray": { "version": "1.0.0", @@ -1119,10 +2058,133 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, + "istanbul-lib-coverage": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", + "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==", + "dev": true + }, + "istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "requires": { + "append-transform": "^2.0.0" + } + }, + "istanbul-lib-instrument": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", + "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "dev": true, + "requires": { + "@babel/core": "^7.7.5", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "istanbul-lib-processinfo": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz", + "integrity": "sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==", + "dev": true, + "requires": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.0", + "istanbul-lib-coverage": "^3.0.0-alpha.1", + "make-dir": "^3.0.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^3.3.3" + } + }, + "istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "istanbul-lib-source-maps": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.0.tgz", + "integrity": "sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "istanbul-reports": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.0.2.tgz", + "integrity": "sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true }, "js-yaml": { "version": "3.13.1", @@ -1133,16 +2195,66 @@ "esprima": "^4.0.0" } }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, "json-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", - "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=" + "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=", + "dev": true }, "json-schema": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.5.tgz", "integrity": "sha512-gWJOWYFrhQ8j7pVm0EM8Slr+EPVq1Phf6lvzvD/WCeqkrx/f2xBI0xOsRRS9xCn3I4vKtP519dvs3TP09r24wQ==" }, + "json2csv": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-5.0.1.tgz", + "integrity": "sha512-QFMifUX1y8W2tKi2TwZpnzf2rHdZvzdmgZUMEMDF46F90f4a9mUeWfx/qg4kzXSZYJYc3cWA5O+eLXk5lj9g8g==", + "requires": { + "commander": "^5.0.0", + "jsonparse": "^1.3.1", + "lodash.get": "^4.4.2" + }, + "dependencies": { + "commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==" + } + } + }, + "json5": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", + "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=" + }, + "jszip": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.5.0.tgz", + "integrity": "sha512-WRtu7TPCmYePR1nazfrtuF216cIVon/3GWOvHS9QR5bIwSbnxtdpma6un3jyGGNhHsKCSzn5Ypk+EkDRvTGiFA==", + "dev": true, + "requires": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "set-immediate-shim": "~1.0.1" + } + }, "kareem": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.3.1.tgz", @@ -1152,6 +2264,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", + "dev": true, "requires": { "json-buffer": "3.0.0" } @@ -1160,10 +2273,20 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz", "integrity": "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==", + "dev": true, "requires": { "package-json": "^6.3.0" } }, + "lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "requires": { + "immediate": "~3.0.5" + } + }, "locate-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", @@ -1177,27 +2300,44 @@ "lodash": { "version": "4.17.15", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + }, + "lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", "dev": true }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, "log-symbols": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", - "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", "dev": true, "requires": { - "chalk": "^2.0.1" + "chalk": "^2.4.2" } }, "lowercase-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", - "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==" + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", + "dev": true }, "make-dir": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.2.tgz", "integrity": "sha512-rYKABKutXa6vXTXhoV18cBE7PaewPXHe/Bdq4v+ZLMhxbWApkFFplT0LcbMW+6BbjnQXzZ/sAvSE/JdguApG5w==", + "dev": true, "requires": { "semver": "^6.0.0" }, @@ -1205,7 +2345,8 @@ "semver": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true } } }, @@ -1251,12 +2392,14 @@ "mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==" + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, "requires": { "brace-expansion": "^1.1.7" } @@ -1264,20 +2407,22 @@ "minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true }, "mkdirp": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, "requires": { "minimist": "^1.2.5" } }, "mocha": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-7.0.0.tgz", - "integrity": "sha512-CirsOPbO3jU86YKjjMzFLcXIb5YiGLUrjrXFHoJ3e2z9vWiaZVCZQ2+gtRGMPWF+nFhN6AWwLM/juzAQ6KRkbA==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-7.1.2.tgz", + "integrity": "sha512-o96kdRKMKI3E8U0bjnfqW4QMk12MwZ4mhdBTf+B5a1q9+aq2HRnj+3ZdJu0B/ZhJeK78MgYuv6L8d/rA5AeBJA==", "dev": true, "requires": { "ansi-colors": "3.2.3", @@ -1291,9 +2436,9 @@ "growl": "1.10.5", "he": "1.2.0", "js-yaml": "3.13.1", - "log-symbols": "2.2.0", + "log-symbols": "3.0.0", "minimatch": "3.0.4", - "mkdirp": "0.5.1", + "mkdirp": "0.5.5", "ms": "2.1.1", "node-environment-flags": "1.0.6", "object.assign": "4.1.0", @@ -1301,8 +2446,8 @@ "supports-color": "6.0.0", "which": "1.3.1", "wide-align": "1.1.3", - "yargs": "13.3.0", - "yargs-parser": "13.1.1", + "yargs": "13.3.2", + "yargs-parser": "13.1.2", "yargs-unparser": "1.6.0" }, "dependencies": { @@ -1351,21 +2496,6 @@ "path-is-absolute": "^1.0.0" } }, - "minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true - }, - "mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", - "dev": true, - "requires": { - "minimist": "0.0.8" - } - }, "ms": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", @@ -1392,6 +2522,11 @@ } } }, + "mongo-sanitize": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mongo-sanitize/-/mongo-sanitize-1.1.0.tgz", + "integrity": "sha512-6gB9AiJD+om2eZLxaPKIP5Q8P3Fr+s+17rVWso7hU0+MAzmIvIMlgTYuyvalDLTtE/p0gczcvJ8A3pbN1XmQ/A==" + }, "mongodb": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.4.1.tgz", @@ -1470,6 +2605,11 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, + "nocache": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/nocache/-/nocache-2.1.0.tgz", + "integrity": "sha512-0L9FvHG3nfnnmaEQPjT9xhfN4ISk0A8/2j4M37Np4mcDesJjHgEUfgPhdCyZuFI954tjokaIj/A3NdpFNdEh4Q==" + }, "node-environment-flags": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.6.tgz", @@ -1480,10 +2620,20 @@ "semver": "^5.7.0" } }, + "node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "requires": { + "process-on-spawn": "^1.0.0" + } + }, "nodemon": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.3.tgz", "integrity": "sha512-lLQLPS90Lqwc99IHe0U94rDgvjo+G9I4uEIxRG3evSLROcqQ9hwc0AxlSHKS4T1JW/IMj/7N5mthiN58NL/5kw==", + "dev": true, "requires": { "chokidar": "^3.2.2", "debug": "^3.2.6", @@ -1501,6 +2651,7 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, "requires": { "ms": "^2.1.1" } @@ -1508,7 +2659,8 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true } } }, @@ -1516,6 +2668,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", "integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=", + "dev": true, "requires": { "abbrev": "1" } @@ -1523,12 +2676,209 @@ "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true }, "normalize-url": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz", - "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==" + "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==", + "dev": true + }, + "nyc": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.0.1.tgz", + "integrity": "sha512-n0MBXYBYRqa67IVt62qW1r/d9UH/Qtr7SF1w/nQLJ9KxvWF6b2xCHImRAixHN9tnMMYHC2P14uo6KddNGwMgGg==", + "dev": true, + "requires": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^2.0.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^4.0.0", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "yargs": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.3.1.tgz", + "integrity": "sha512-92O1HWEjw27sBfgmXiixJWT5hRBp2eobqXicLtPBIDBhYB+1HpwZlXmbW2luivBJHBzki+7VyCLRtAkScbTBQA==", + "dev": true, + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.1" + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-inspect": { "version": "1.7.0", @@ -1572,23 +2922,41 @@ "ee-first": "1.1.1" } }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, "requires": { "wrappy": "1" } }, + "openapi-types": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-1.3.5.tgz", + "integrity": "sha512-11oi4zYorsgvg5yBarZplAqbpev5HkuVNPlZaPTknPDzAynq+lnJdXAmruGWP0s+dNYZS7bjM+xrTpJw7184Fg==" + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, "p-cancelable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", - "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==" + "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", + "dev": true }, "p-limit": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.2.tgz", - "integrity": "sha512-WGR+xHecKTr7EbUEhyLSh5Dube9JtdiG78ufaeLxTgpudf/20KqyMioIUZJAezlTIi6evxuoUs9YXc11cU+yzQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, "requires": { "p-try": "^2.0.0" @@ -1603,16 +2971,38 @@ "p-limit": "^2.0.0" } }, + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, "p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true }, + "package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + } + }, "package-json": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz", "integrity": "sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==", + "dev": true, "requires": { "got": "^9.6.0", "registry-auth-token": "^4.0.0", @@ -1623,10 +3013,17 @@ "semver": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true } } }, + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1641,22 +3038,133 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true }, "path-parse": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true }, "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" }, + "pdf2json": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pdf2json/-/pdf2json-1.2.0.tgz", + "integrity": "sha512-Z/m+OFOe13Nn2SHQNSINZ6Mh2b8t2bK3whL3L6b5Av1wqDvotYvpMg1Zi8aEPV37jF0jG0yQ83c8XuuNbIsn6Q==", + "dev": true, + "requires": { + "async": "^3.2.0", + "lodash": "^4.17.13", + "optimist": "^0.6.1", + "xmldom": "^0.3.0" + }, + "dependencies": { + "async": { + "version": "3.2.0", + "bundled": true, + "dev": true + }, + "lodash": { + "version": "4.17.15", + "bundled": true, + "dev": true + }, + "minimist": { + "version": "0.0.10", + "bundled": true, + "dev": true + }, + "optimist": { + "version": "0.6.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + } + }, + "wordwrap": { + "version": "0.0.3", + "bundled": true, + "dev": true + }, + "xmldom": { + "version": "0.3.0", + "bundled": true, + "dev": true + } + } + }, + "pdfreader": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/pdfreader/-/pdfreader-1.0.7.tgz", + "integrity": "sha512-3hX/PpA/MQV2uvSiR2CH7isuyZXqYPoA6IXOxHd7hw9qS6Lz9RKYKu+iU369+OgkJKe/SHpxwEbgoHBV4L/76w==", + "dev": true, + "requires": { + "pdf2json": "^1.1.8" + } + }, "picomatch": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.1.tgz", - "integrity": "sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA==" + "integrity": "sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + } + } }, "ports": { "version": "1.1.0", @@ -1666,7 +3174,8 @@ "prepend-http": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", - "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=" + "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", + "dev": true }, "process-nextick-args": { "version": "2.0.1", @@ -1674,6 +3183,15 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "requires": { + "fromentries": "^1.2.0" + } + }, "proxy-addr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.5.tgz", @@ -1686,12 +3204,14 @@ "pstree.remy": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.7.tgz", - "integrity": "sha512-xsMgrUwRpuGskEzBFkH8NmTimbZ5PcPup0LA8JJkHIm2IMUbQcpo3yeLNWVrufEYjh8YwtSVh0xz6UeWc5Oh5A==" + "integrity": "sha512-xsMgrUwRpuGskEzBFkH8NmTimbZ5PcPup0LA8JJkHIm2IMUbQcpo3yeLNWVrufEYjh8YwtSVh0xz6UeWc5Oh5A==", + "dev": true }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, "requires": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -1701,6 +3221,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.0.1.tgz", "integrity": "sha512-hEJH0s8PXLY/cdXh66tNEQGndDrIKNqNC5xmrysZy3i5C3oEoLna7YAOad+7u125+zH1HNXUmGEkrhb3c2VriA==", + "dev": true, "requires": { "escape-goat": "^2.0.0" } @@ -1724,12 +3245,23 @@ "http-errors": "1.7.2", "iconv-lite": "0.4.24", "unpipe": "1.0.0" + }, + "dependencies": { + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + } } }, "rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, "requires": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -1756,10 +3288,16 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.3.0.tgz", "integrity": "sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ==", + "dev": true, "requires": { "picomatch": "^2.0.7" } }, + "referrer-policy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/referrer-policy/-/referrer-policy-1.2.0.tgz", + "integrity": "sha512-LgQJIuS6nAy1Jd88DCQRemyE3mS+ispwlqMk3b0yjZ257fI1v9c+/p6SD5gP5FGyXUIgrNOAfmyioHwZtYv2VA==" + }, "regexp-clone": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-1.0.0.tgz", @@ -1769,6 +3307,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.1.1.tgz", "integrity": "sha512-9bKS7nTl9+/A1s7tnPeGrUpRcVY+LUh7bfFgzpndALdPfXQBfQV77rQVtqgUV3ti4vc/Ik81Ex8UJDWDQ12zQA==", + "dev": true, "requires": { "rc": "^1.2.8" } @@ -1777,10 +3316,20 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-5.1.0.tgz", "integrity": "sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==", + "dev": true, "requires": { "rc": "^1.2.8" } }, + "release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=", + "dev": true, + "requires": { + "es6-error": "^4.0.1" + } + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1806,6 +3355,7 @@ "version": "1.14.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.14.2.tgz", "integrity": "sha512-EjlOBLBO1kxsUxsKjLt7TAECyKW6fOh1VRkykQkKGzcBbjjPIxBqGh0jf7GJ3k/f5mxMqW3htMD3WdTUVtW8HQ==", + "dev": true, "requires": { "path-parse": "^1.0.6" } @@ -1819,10 +3369,20 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", + "dev": true, "requires": { "lowercase-keys": "^1.0.0" } }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -1842,6 +3402,28 @@ "sparse-bitfield": "^3.0.3" } }, + "selenium-webdriver": { + "version": "4.0.0-alpha.7", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.0.0-alpha.7.tgz", + "integrity": "sha512-D4qnTsyTr91jT8f7MfN+OwY0IlU5+5FmlO5xlgRUV6hDEV8JyYx2NerdTEqDDkNq7RZDYc4VoPALk8l578RBHw==", + "dev": true, + "requires": { + "jszip": "^3.2.2", + "rimraf": "^2.7.1", + "tmp": "0.0.30" + }, + "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -1851,6 +3433,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", "integrity": "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==", + "dev": true, "requires": { "semver": "^6.3.0" }, @@ -1858,7 +3441,8 @@ "semver": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true } } }, @@ -1906,11 +3490,32 @@ "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "dev": true }, + "set-immediate-shim": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", + "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=", + "dev": true + }, "setprototypeof": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, "should": { "version": "13.2.3", "resolved": "https://registry.npmjs.org/should/-/should-13.2.3.tgz", @@ -1973,13 +3578,20 @@ "signal-exit": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", - "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true }, "sliced": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz", "integrity": "sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E=" }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, "sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -1989,6 +3601,31 @@ "memory-pager": "^1.0.2" } }, + "spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "requires": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "dependencies": { + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -2009,24 +3646,46 @@ "strip-ansi": "^4.0.0" } }, - "string.prototype.trimleft": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz", - "integrity": "sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag==", + "string.prototype.trimend": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz", + "integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==", "dev": true, "requires": { "define-properties": "^1.1.3", - "function-bind": "^1.1.1" + "es-abstract": "^1.17.5" + } + }, + "string.prototype.trimleft": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz", + "integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5", + "string.prototype.trimstart": "^1.0.0" } }, "string.prototype.trimright": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz", - "integrity": "sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz", + "integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==", "dev": true, "requires": { "define-properties": "^1.1.3", - "function-bind": "^1.1.1" + "es-abstract": "^1.17.5", + "string.prototype.trimend": "^1.0.0" + } + }, + "string.prototype.trimstart": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz", + "integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" } }, "string_decoder": { @@ -2047,10 +3706,17 @@ "ansi-regex": "^3.0.0" } }, + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true + }, "strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true }, "superagent": { "version": "3.8.3", @@ -2101,6 +3767,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, "requires": { "has-flag": "^3.0.0" } @@ -2121,17 +3788,55 @@ "term-size": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.0.tgz", - "integrity": "sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw==" + "integrity": "sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw==", + "dev": true + }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } + }, + "through2": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.1.tgz", + "integrity": "sha512-M96dvTalPT3YbYLaKaCuwu+j06D/8Jfib0o/PxbVt6Amhv3dUAtW6rTV1jPgJSBG83I/e04Y6xkVdVhSRhi0ww==", + "dev": true, + "requires": { + "readable-stream": "2 || 3" + } + }, + "tmp": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.30.tgz", + "integrity": "sha1-ckGdSovn1s51FI/YsyTlk6cRwu0=", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.1" + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true }, "to-readable-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", - "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==" + "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", + "dev": true }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "requires": { "is-number": "^7.0.0" } @@ -2145,6 +3850,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, "requires": { "nopt": "~1.0.10" } @@ -2152,12 +3858,14 @@ "tslib": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", - "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==" + "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==", + "dev": true }, "tslint": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.20.1.tgz", "integrity": "sha512-EcMxhzCFt8k+/UP5r8waCf/lzmeSyVlqxqMEDQE7rWYiQky8KpIBz1JAoYXfROHrPZ1XXd43q8yQnULOLiBRQg==", + "dev": true, "requires": { "@babel/code-frame": "^7.0.0", "builtin-modules": "^1.1.1", @@ -2178,6 +3886,7 @@ "version": "2.29.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", + "dev": true, "requires": { "tslib": "^1.8.1" } @@ -2185,7 +3894,8 @@ "type-fest": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==" + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true }, "type-is": { "version": "1.6.18", @@ -2200,6 +3910,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, "requires": { "is-typedarray": "^1.0.0" } @@ -2207,12 +3918,14 @@ "typescript": { "version": "3.7.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.7.4.tgz", - "integrity": "sha512-A25xv5XCtarLwXpcDNZzCGvW2D1S3/bACratYBx2sax8PefsFhlYmkQicKHvpYflFS8if4zne5zT5kpJ7pzuvw==" + "integrity": "sha512-A25xv5XCtarLwXpcDNZzCGvW2D1S3/bACratYBx2sax8PefsFhlYmkQicKHvpYflFS8if4zne5zT5kpJ7pzuvw==", + "dev": true }, "undefsafe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.3.tgz", "integrity": "sha512-nrXZwwXrD/T/JXeygJqdCO6NZZ1L66HrxM/Z7mIq2oPanoN0F1nLx3lwJMu6AwJY69hdixaFQOuoYsMjE5/C2A==", + "dev": true, "requires": { "debug": "^2.2.0" } @@ -2226,6 +3939,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, "requires": { "crypto-random-string": "^2.0.0" } @@ -2239,6 +3953,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-4.1.0.tgz", "integrity": "sha512-w3doE1qtI0/ZmgeoDoARmI5fjDoT93IfKgEGqm26dGUOh8oNpaSTsGNdYRN/SjOuo10jcJGwkEL3mroKzktkew==", + "dev": true, "requires": { "boxen": "^4.2.0", "chalk": "^3.0.0", @@ -2259,6 +3974,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, "requires": { "@types/color-name": "^1.1.1", "color-convert": "^2.0.1" @@ -2268,6 +3984,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2277,6 +3994,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "requires": { "color-name": "~1.1.4" } @@ -2284,17 +4002,20 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true }, "supports-color": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -2305,6 +4026,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", + "dev": true, "requires": { "prepend-http": "^2.0.0" } @@ -2320,6 +4042,17 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + }, + "validator": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-12.2.0.tgz", + "integrity": "sha512-jJfE/DW6tIK1Ek8nCfNFqt8Wb3nzMoAbocBF6/Icgg1ZFSBpObdnwVY2jQj6qUqzhx5jc71fpvBWyLGO7Xl+nQ==" + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2353,6 +4086,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dev": true, "requires": { "string-width": "^4.0.0" }, @@ -2360,22 +4094,26 @@ "ansi-regex": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true }, "string-width": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -2386,6 +4124,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, "requires": { "ansi-regex": "^5.0.0" } @@ -2434,12 +4173,14 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true }, "write-file-atomic": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, "requires": { "imurmurhash": "^0.1.4", "is-typedarray": "^1.0.0", @@ -2447,10 +4188,16 @@ "typedarray-to-buffer": "^3.1.5" } }, + "x-xss-protection": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/x-xss-protection/-/x-xss-protection-1.3.0.tgz", + "integrity": "sha512-kpyBI9TlVipZO4diReZMAHWtS0MMa/7Kgx8hwG/EuZLiA6sg4Ah/4TRdASHhRRN3boobzcYgFRUFSgHRge6Qhg==" + }, "xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", - "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==" + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "dev": true }, "y18n": { "version": "4.0.0", @@ -2459,9 +4206,9 @@ "dev": true }, "yargs": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.0.tgz", - "integrity": "sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA==", + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", "dev": true, "requires": { "cliui": "^5.0.0", @@ -2473,7 +4220,7 @@ "string-width": "^3.0.0", "which-module": "^2.0.0", "y18n": "^4.0.0", - "yargs-parser": "^13.1.1" + "yargs-parser": "^13.1.2" }, "dependencies": { "ansi-regex": { @@ -2505,21 +4252,13 @@ } }, "yargs-parser": { - "version": "13.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.1.tgz", - "integrity": "sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==", + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", "dev": true, "requires": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" - }, - "dependencies": { - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true - } } }, "yargs-unparser": { @@ -2532,6 +4271,17 @@ "lodash": "^4.17.15", "yargs": "^13.3.0" } + }, + "z-schema": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-4.2.3.tgz", + "integrity": "sha512-zkvK/9TC6p38IwcrbnT3ul9in1UX4cm1y/VZSs4GHKIiDCrlafc+YQBgQBUdDXLAoZHf2qvQ7gJJOo6yT1LH6A==", + "requires": { + "commander": "^2.7.1", + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^12.0.0" + } } } } diff --git a/package.json b/package.json index 5c89ff2..f9494d3 100644 --- a/package.json +++ b/package.json @@ -4,29 +4,61 @@ "description": "API for the digital fingerprint of plastics mongodb", "main": "index.js", "scripts": { + "tsc": "tsc", + "tsc-full": "del /q dist\\* & (for /d %x in (dist\\*) do @rd /s /q \"%x\") & tsc", + "build": "build.bat", + "build-push": "build.bat && cf push", "test": "mocha dist/**/**.spec.js", - "start": "tsc && node dist/index.js", - "dev": "nodemon -e ts,yaml --exec \"npm run start\"" + "start": "node index.js", + "dev": "nodemon -e ts,yaml --exec \"tsc && node dist/index.js || exit 1\"", + "loadDev": "node dist/test/loadDev.js", + "coverage": "tsc && nyc --reporter=html --reporter=text mocha dist/**/**.spec.js --timeout 5000", + "import": "node data_import/import.js" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@apidevtools/json-schema-ref-parser": "^8.0.0", - "@types/mocha": "^5.2.7", - "@types/node": "^13.1.6", + "@apidevtools/swagger-parser": "^9.0.1", + "@hapi/joi": "^17.1.1", + "axios": "^0.19.2", + "basic-auth": "^2.0.1", + "bcryptjs": "^2.4.3", + "body-parser": "^1.19.0", "cfenv": "^1.2.2", + "compression": "^1.7.4", + "content-filter": "^1.1.2", + "cors": "^2.8.5", "express": "^4.17.1", + "helmet": "^3.22.0", "json-schema": "^0.2.5", + "json2csv": "^5.0.1", + "lodash": "^4.17.15", + "mongo-sanitize": "^1.1.0", "mongoose": "^5.8.7", - "nodemon": "^2.0.3", - "swagger-ui-express": "^4.1.2", - "tslint": "^5.20.1", - "typescript": "^3.7.4" + "swagger-ui-express": "4.1.2" }, "devDependencies": { - "mocha": "^7.0.0", + "@types/bcrypt": "^3.0.0", + "@types/body-parser": "^1.19.0", + "@types/express-serve-static-core": "^4.17.5", + "@types/lodash": "^4.14.150", + "@types/mocha": "^5.2.7", + "@types/mongoose": "^5.7.12", + "@types/node": "^13.1.6", + "@types/qs": "^6.9.1", + "@types/serve-static": "^1.13.3", + "csv-parser": "^2.3.3", + "iconv-lite": "^0.6.0", + "mocha": "^7.1.2", + "nodemon": "^2.0.3", + "nyc": "^15.0.1", + "pdfreader": "^1.0.7", + "selenium-webdriver": "^4.0.0-alpha.7", "should": "^13.2.3", - "supertest": "^4.0.2" + "supertest": "^4.0.2", + "tslint": "^5.20.1", + "typescript": "^3.7.4" } } diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..aab7b80 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,48 @@ +import swagger from 'swagger-ui-express'; +import jsonRefParser, {JSONSchema} from '@apidevtools/json-schema-ref-parser'; +import oasParser from '@apidevtools/swagger-parser'; + + +// modifies the normal swagger-ui-express package +// usage: app.use('/api-doc', api.serve(), api.setup()); +// the paths property can be split using allOf +// further route documentation can be included in the x-doc property + +export default class api { + static serve () { + return swagger.serve; + } + + static setup () { + let apiDoc: JSONSchema = {}; + jsonRefParser.bundle('api/api.yaml', (err, doc) => { // parse yaml + if (err) throw err; + apiDoc = doc; + apiDoc.servers.splice(process.env.NODE_ENV === 'production', 1); + apiDoc.paths = apiDoc.paths.allOf.reduce((s, e) => Object.assign(s, e)); // bundle routes + apiDoc = this.resolveXDoc(apiDoc); + oasParser.validate(apiDoc, (err, api) => { // validate oas schema + if (err) { + console.error(err); + } + else { + console.info(process.env.NODE_ENV === 'test' ? '' : 'API ok, version ' + api.info.version); + swagger.setup(apiDoc); + } + }); + }); + return swagger.setup(apiDoc, {customCssUrl: '/static/styles/swagger.css'}) + } + + private static resolveXDoc (doc) { // resolve x-doc properties recursively + Object.keys(doc).forEach(key => { + if (doc[key] !== null && doc[key].hasOwnProperty('x-doc')) { // add x-doc to description, is styled via css + doc[key].description += '
docs' + doc[key]['x-doc'] + '
'; + } + else if (typeof doc[key] === 'object' && doc[key] !== null) { // go deeper into recursion + doc[key] = this.resolveXDoc(doc[key]); + } + }); + return doc; + } +} \ No newline at end of file diff --git a/src/customTypes/express.ts b/src/customTypes/express.ts new file mode 100644 index 0000000..361c961 --- /dev/null +++ b/src/customTypes/express.ts @@ -0,0 +1,116 @@ +// Type definitions for Express 4.17 +// Project: http://expressjs.com +// Definitions by: Boris Yankov +// China Medical University Hospital +// Puneet Arora +// Dylan Frankland +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped +// TypeScript Version: 2.3 + +/* =================== USAGE =================== + + import * as express from "express"; + var app = express(); + + =============================================== */ + +/// +/// + +import * as bodyParser from "body-parser"; +import serveStatic = require("serve-static"); +import * as core from "express-serve-static-core"; +import * as qs from "qs"; + +/** + * Creates an Express application. The express() function is a top-level function exported by the express module. + */ +declare function e(): core.Express; + +declare namespace e { + /** + * This is a built-in middleware function in Express. It parses incoming requests with JSON payloads and is based on body-parser. + * @since 4.16.0 + */ + var json: typeof bodyParser.json; + + /** + * This is a built-in middleware function in Express. It parses incoming requests with Buffer payloads and is based on body-parser. + * @since 4.17.0 + */ + var raw: typeof bodyParser.raw; + + /** + * This is a built-in middleware function in Express. It parses incoming requests with text payloads and is based on body-parser. + * @since 4.17.0 + */ + var text: typeof bodyParser.text; + + /** + * These are the exposed prototypes. + */ + var application: Application; + var request: Request; + var response: Response; + + /** + * This is a built-in middleware function in Express. It serves static files and is based on serve-static. + */ + var static: typeof serveStatic; + + /** + * This is a built-in middleware function in Express. It parses incoming requests with urlencoded payloads and is based on body-parser. + * @since 4.16.0 + */ + var urlencoded: typeof bodyParser.urlencoded; + + /** + * This is a built-in middleware function in Express. It parses incoming request query parameters. + */ + export function query(options: qs.IParseOptions | typeof qs.parse): Handler; + + export function Router(options?: RouterOptions): core.Router; + + interface RouterOptions { + /** + * Enable case sensitivity. + */ + caseSensitive?: boolean; + + /** + * Preserve the req.params values from the parent router. + * If the parent and the child have conflicting param names, the child’s value take precedence. + * + * @default false + * @since 4.5.0 + */ + mergeParams?: boolean; + + /** + * Enable strict routing. + */ + strict?: boolean; + } + + interface Application extends core.Application { } + interface CookieOptions extends core.CookieOptions { } + interface Errback extends core.Errback { } + interface ErrorRequestHandler

+ extends core.ErrorRequestHandler { } + interface Express extends core.Express { } + interface Handler extends core.Handler { } + interface IRoute extends core.IRoute { } + interface IRouter extends core.IRouter { } + interface IRouterHandler extends core.IRouterHandler { } + interface IRouterMatcher extends core.IRouterMatcher { } + interface MediaType extends core.MediaType { } + interface NextFunction extends core.NextFunction { } + interface Request

extends core.Request { } + interface RequestHandler

extends core.RequestHandler { } + interface RequestParamHandler extends core.RequestParamHandler { } + export interface Response extends core.Response { } + interface Router extends core.Router { } + interface Send extends core.Send { } +} + +export = e; diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..2bab005 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,158 @@ +import mongoose from 'mongoose'; +import cfenv from 'cfenv'; +import _ from 'lodash'; +import ChangelogModel from './models/changelog'; + + +// database urls, prod db url is retrieved automatically +const TESTING_URL = 'mongodb://localhost/dfopdb_test'; +const DEV_URL = 'mongodb://localhost/dfopdb'; +const debugging = true; + +if (process.env.NODE_ENV !== 'production' && debugging) { + mongoose.set('debug', true); // enable mongoose debug +} + +export default class db { + private static state = { // db object and current mode (test, dev, prod) + db: null, + mode: null, + }; + + static connect (mode = '', done: Function = () => {}) { // set mode to test for unit/integration tests, otherwise skip parameters. done is also only needed for testing + if (this.state.db) return done(); // db is already connected + + // find right connection url + let connectionString: string = ""; + if (mode === 'test') { // testing + connectionString = TESTING_URL; + this.state.mode = 'test'; + } + else if(process.env.NODE_ENV === 'production') { + let services = cfenv.getAppEnv().getServices(); + for (let service in services) { + if(services[service].tags.indexOf("mongodb") >= 0) { + connectionString = services[service]["credentials"].uri; + } + } + this.state.mode = 'prod'; + } + else { + connectionString = DEV_URL; + this.state.mode = 'dev'; + } + + // connect to db + mongoose.connect(connectionString, {useNewUrlParser: true, useUnifiedTopology: true, useCreateIndex: true, connectTimeoutMS: 10000}, err => { + if (err) done(err); + }); + mongoose.connection.on('error', console.error.bind(console, 'connection error:')); + mongoose.connection.on('connected', () => { // evaluation connection behaviour on prod + if (process.env.NODE_ENV !== 'test') { // Do not interfere with testing + console.info('Database connected'); + } + }); + mongoose.connection.on('disconnected', () => { // reset state on disconnect + if (process.env.NODE_ENV !== 'test') { // Do not interfere with testing + console.info('Database disconnected'); + // this.state.db = 0; // prod database connects and disconnects automatically + } + }); + process.on('SIGINT', () => { // close connection when app is terminated + if (!this.state.db) { // database still connected + mongoose.connection.close(() => { + console.info('Mongoose default connection disconnected through app termination'); + process.exit(0); + }); + } + }); + mongoose.connection.once('open', () => { + mongoose.set('useFindAndModify', false); + console.info(process.env.NODE_ENV === 'test' ? '' : `Connected to ${connectionString}`); + this.state.db = mongoose.connection; + done(); + }); + } + + static disconnect (done) { + mongoose.connection.close(() => { + console.info(process.env.NODE_ENV === 'test' ? '' : `Disconnected from database`); + this.state.db = 0; + done(); + }); + } + + static getState () { + return this.state; + } + + static drop (done: Function = () => {}) { // drop all collections of connected db (only dev and test for safety reasons ;) + if (!this.state.db || this.state.mode === 'prod') return done(); // no db connection or prod db + this.state.db.db.listCollections().toArray((err, collections) => { // get list of all collections + if (collections.length === 0) { // there are no collections to drop + return done(); + } + else { + let dropCounter = 0; // count number of dropped collections to know when to return done() + collections.forEach(collection => { // drop each collection + this.state.db.dropCollection(collection.name, () => { + if (++ dropCounter >= collections.length) { // all collections dropped + done(); + } + }); + }); + } + }); + } + + static loadJson (json, done: Function = () => {}) { // insert given JSON data into db, uses core mongodb methods + if (!this.state.db || !json.hasOwnProperty('collections') || json.collections.length === 0) { // no db connection or nothing to load + return done(); + } + + let loadCounter = 0; // count number of loaded collections to know when to return done() + Object.keys(json.collections).forEach(collectionName => { // create each collection + json.collections[collectionName] = this.oidResolve(json.collections[collectionName]); + this.state.db.createCollection(collectionName, (err, collection) => { + collection.insertMany(json.collections[collectionName], () => { // insert JSON data + if (++ loadCounter >= Object.keys(json.collections).length) { // all collections loaded + done(); + } + }); + }); + }); + } + + // changelog entry + static log(req, thisOrCollection, conditions = null, data = null) { // expects (req, this (from query helper)) or (req, collection, conditions, data) + if (! (conditions || data)) { // (req, this) + data = thisOrCollection._update ? _.cloneDeep(thisOrCollection._update) : {}; // replace undefined with {} + Object.keys(data).forEach(key => { + if (key[0] === '$') { + data[key.substr(1)] = data[key]; + delete data[key]; + } + }); + new ChangelogModel({action: req.method + ' ' + req.url, collectionName: thisOrCollection._collection.collectionName, conditions: thisOrCollection._conditions, data: data, user_id: req.authDetails.id ? req.authDetails.id : null}).save(err => { + if (err) console.error(err); + }); + } + else { // (req, collection, conditions, data) + new ChangelogModel({action: req.method + ' ' + req.url, collectionName: thisOrCollection, conditions: conditions, data: data, user_id: req.authDetails.id ? req.authDetails.id : null}).save(err => { + if (err) console.error(err); + }); + } + } + + private static oidResolve (object: any) { // resolve $oid fields to actual ObjectIds recursively + Object.keys(object).forEach(key => { + if (object[key] !== null && object[key].hasOwnProperty('$oid')) { // found oid, replace + object[key] = mongoose.Types.ObjectId(object[key].$oid); + } + else if (typeof object[key] === 'object' && object[key] !== null) { // deeper into recursion + object[key] = this.oidResolve(object[key]); + } + }); + return object; + } +}; diff --git a/src/globals.ts b/src/globals.ts new file mode 100644 index 0000000..81f80b8 --- /dev/null +++ b/src/globals.ts @@ -0,0 +1,17 @@ +const globals = { + levels: [ // access levels + 'read', + 'write', + 'maintain', + 'dev', + 'admin' + ], + + status: { // document statuses + deleted: -1, + new: 0, + validated: 10, + } +}; + +export default globals; \ No newline at end of file diff --git a/src/helpers/authorize.ts b/src/helpers/authorize.ts new file mode 100644 index 0000000..03d344b --- /dev/null +++ b/src/helpers/authorize.ts @@ -0,0 +1,105 @@ +import basicAuth from 'basic-auth'; +import bcrypt from 'bcryptjs'; +import UserModel from '../models/user'; + + +// appends req.auth(res, ['levels'], method = 'all') +// which returns sends error message and returns false if unauthorized, otherwise true +// req.authDetails returns eg. {methods: ['basic'], username: 'johndoe', level: 'write'} + +module.exports = async (req, res, next) => { + let givenMethod = ''; // authorization method given by client, basic taken preferred + let user = {name: '', level: '', id: '', location: ''}; // user object + + // test authentications + const userBasic = await basic(req, next); + + if (userBasic) { // basic available + givenMethod = 'basic'; + user = userBasic; + } + else { // if basic not available, test key + const userKey = await key(req, next); + if (userKey) { + givenMethod = 'key'; + user = userKey; + } + } + + req.auth = (res, levels, method = 'all') => { + if (givenMethod === method || (method === 'all' && givenMethod !== '')) { // method is available + if (levels.indexOf(user.level) > -1) { // level is available + return true; + } + else { + res.status(403).json({status: 'Forbidden'}); + return false; + } + } + else { + res.status(401).json({status: 'Unauthorized'}); + return false; + } + } + + req.authDetails = { + method: givenMethod, + username: user.name, + level: user.level, + id: user.id, + location: user.location + }; + + next(); +} + + +function basic (req, next): any { // checks basic auth and returns changed user object + return new Promise(resolve => { + const auth = basicAuth(req); + if (auth !== undefined) { // basic auth available + UserModel.find({name: auth.name}).lean().exec( (err, data: any) => { // find user + if (err) return next(err); + if (data.length === 1) { // one user found + bcrypt.compare(auth.pass, data[0].pass, (err, res) => { // check password + if (err) return next(err); + if (res === true) { // password correct + resolve({level: data[0].level, name: data[0].name, id: data[0]._id.toString(), location: data[0].location}); + } + else { + resolve(null); + } + }); + } + else { + resolve(null); + } + }); + } + else { + resolve(null); + } + }); +} + +function key (req, next): any { // checks API key and returns changed user object + return new Promise(resolve => { + if (req.query.key !== undefined) { // key available + UserModel.find({key: req.query.key}).lean().exec( (err, data: any) => { // find user + if (err) return next(err); + if (data.length === 1) { // one user found + resolve({level: data[0].level, name: data[0].name, id: data[0]._id.toString(), location: data[0].location}); + if (!/^\/api/m.test(req.url)){ + delete req.query.key; // delete query parameter to avoid interference with later validation + } + } + else { + resolve(null); + } + }); + } + else { + resolve(null); + } + }); +} \ No newline at end of file diff --git a/src/helpers/csv.ts b/src/helpers/csv.ts new file mode 100644 index 0000000..38c487a --- /dev/null +++ b/src/helpers/csv.ts @@ -0,0 +1,34 @@ +import {parseAsync} from 'json2csv'; + +export default function csv(input: any[], f: (err, data) => void) { + parseAsync(input.map(e => flatten(e)), {includeEmptyRows: true}) + .then(csv => f(null, csv)) + .catch(err => f(err, null)); +} + +function flatten (data) { // flatten object: {a: {b: true}} -> {a.b: true} + const result = {}; + function recurse (cur, prop) { + if (Object(cur) !== cur || Object.keys(cur).length === 0) { + result[prop] = cur; + } + else if (Array.isArray(cur)) { + let l = 0; + for(let i = 0, l = cur.length; i < l; i++) + recurse(cur[i], prop + "[" + i + "]"); + if (l == 0) + result[prop] = []; + } + else { + let isEmpty = true; + for (let p in cur) { + isEmpty = false; + recurse(cur[p], prop ? prop+"."+p : p); + } + if (isEmpty && prop) + result[prop] = {}; + } + } + recurse(data, ''); + return result; +} \ No newline at end of file diff --git a/src/helpers/mail.ts b/src/helpers/mail.ts new file mode 100644 index 0000000..8ec71c8 --- /dev/null +++ b/src/helpers/mail.ts @@ -0,0 +1,64 @@ +import axios from 'axios'; + +// sends an email using the BIC service + +export default (mailAddress, subject, content, f) => { // callback, executed empty or with error + if (process.env.NODE_ENV === 'production') { + const mailService = JSON.parse(process.env.VCAP_SERVICES).Mail[0]; + axios({ + method: 'post', + url: mailService.credentials.uri + '/email', + auth: {username: mailService.credentials.username, password: mailService.credentials.password}, + data: { + recipients: [{to: mailAddress}], + subject: {content: subject}, + body: { + content: content, + contentType: "text/html" + }, + from: { + eMail: "definma@bosch-iot.com", + password: "PlasticsOfFingerprintDigital" + } + } + }) + .then(() => { + f(); + }) + .catch((err) => { + f(err); + }); + } + else if (process.env.NODE_ENV === 'test') { + console.info('Sending mail to ' + mailAddress + ': -- ' + subject + ' -- ' + content); + f(); + } + else { // dev + axios({ + method: 'get', + url: 'https://digital-fingerprint-of-plastics-mail-test.apps.de1.bosch-iot-cloud.com/api', + data: { + method: 'post', + url: '/email', + data: { + recipients: [{to: mailAddress}], + subject: {content: subject}, + body: { + content: content, + contentType: "text/html" + }, + from: { + eMail: "dfop-test@bosch-iot.com", + password: "PlasticsOfFingerprintDigital" + } + } + } + }) + .then(() => { + f(); + }) + .catch((err) => { + f(err); + }); + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 09fb57f..d6ea865 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,37 +1,21 @@ -import cfenv from 'cfenv'; import express from 'express'; -import mongoose from 'mongoose'; -import swagger from 'swagger-ui-express'; -import jsonRefParser, {JSONSchema} from '@apidevtools/json-schema-ref-parser'; +import bodyParser from 'body-parser'; +import compression from 'compression'; +import contentFilter from 'content-filter'; +import mongoSanitize from 'mongo-sanitize'; +import helmet from 'helmet'; +import cors from 'cors'; +import api from './api'; +import db from './db'; +// TODO: working demo branch // tell if server is running in debug or production environment -console.log(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : '===== DEVELOPMENT ====='); - - -// get mongodb address from server, otherwise set to localhost -let connectionString: string = ""; -if(process.env.NODE_ENV === 'production') { - let services = cfenv.getAppEnv().getServices(); - for (let service in services) { - if(services[service].tags.indexOf("mongodb") >= 0) { - connectionString = services[service]["credentials"].uri; - } - } -} -else { - connectionString = 'mongodb://localhost/dfopdb'; -} -mongoose.connect(connectionString, {useNewUrlParser: true, useUnifiedTopology: true}); - -// connect to mongodb -let db = mongoose.connection; -db.on('error', console.error.bind(console, 'connection error:')); -db.once('open', () => { - console.log(`Connected to ${connectionString}`); -}); +console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); +// mongodb connection +db.connect(); // create Express app const app = express(); @@ -40,20 +24,68 @@ app.disable('x-powered-by'); // get port from environment, defaults to 3000 const port = process.env.PORT || 3000; +//middleware +app.use(helmet()); +app.use(contentFilter()); // filter URL query attacks +app.use(express.json({ limit: '5mb'})); +app.use(express.urlencoded({ extended: false, limit: '5mb' })); +app.use(compression()); // compress responses +app.use(bodyParser.json()); +app.use((req, res, next) => { // filter body query attacks + req.body = mongoSanitize(req.body); + next(); +}); +app.use((err, req, res, ignore) => { // bodyParser error handling + res.status(400).send({status: 'Invalid JSON body'}); +}); +app.use((req, res, next) => { // no database connection error + if (db.getState().db) { + next(); + } + else { + console.error('No database connection'); + res.status(500).send({status: 'Internal server error'}); + } +}); +app.use(cors()); // CORS headers +app.use(require('./helpers/authorize')); // handle authentication + +// redirect /api routes for Angular proxy in development +if (process.env.NODE_ENV !== 'production') { + app.use('/api/:url([^]+)', (req, res) => { + req.url = '/' + req.params.url; + app.handle(req, res); + }); +} + + // require routes app.use('/', require('./routes/root')); +app.use('/', require('./routes/sample')); +app.use('/', require('./routes/material')); +app.use('/', require('./routes/template')); +app.use('/', require('./routes/user')); +app.use('/', require('./routes/measurement')); + +// static files +app.use('/static', express.static('static')); // Swagger UI -let oasDoc: JSONSchema = {}; -jsonRefParser.bundle('oas/oas.yaml', (err, doc) => { - if(err) throw err; - oasDoc = doc; - oasDoc.paths = oasDoc.paths.allOf.reduce((s, e) => Object.assign(s, e)); - swagger.setup(oasDoc, {defaultModelsExpandDepth: -1, customCss: '.swagger-ui .topbar { display: none }'}); +app.use('/api-doc', api.serve(), api.setup()); + +app.use((req, res) => { // 404 error handling + res.status(404).json({status: 'Not found'}); }); -app.use('/api', swagger.serve, swagger.setup(oasDoc, {defaultModelsExpandDepth: -1, customCss: '.swagger-ui .topbar { display: none }'})); + +app.use((err, req, res, ignore) => { // internal server error handling + console.error(err); + res.status(500).json({status: 'Internal server error'}); +}); + // hook up server to port -app.listen(port, () => { - console.log(`Listening on http;//localhost:${port}`); +const server = app.listen(port, () => { + console.info(process.env.NODE_ENV === 'test' ? '' : `Listening on http://localhost:${port}`); }); + +module.exports = server; \ No newline at end of file diff --git a/src/models/changelog.ts b/src/models/changelog.ts new file mode 100644 index 0000000..75600c4 --- /dev/null +++ b/src/models/changelog.ts @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; + +const ChangelogSchema = new mongoose.Schema({ + action: String, + collectionName: String, + conditions: Object, + data: Object, + user_id: mongoose.Schema.Types.ObjectId +}, {minimize: false}); + +export default mongoose.model>('changelog', ChangelogSchema); \ No newline at end of file diff --git a/src/models/condition_template.ts b/src/models/condition_template.ts new file mode 100644 index 0000000..ca61da2 --- /dev/null +++ b/src/models/condition_template.ts @@ -0,0 +1,20 @@ +import mongoose from 'mongoose'; +import db from '../db'; + +const ConditionTemplateSchema = new mongoose.Schema({ + first_id: mongoose.Schema.Types.ObjectId, + name: String, + version: Number, + parameters: [new mongoose.Schema({ + name: String, + range: mongoose.Schema.Types.Mixed + } ,{ _id : false })] +}, {minimize: false}); // to allow empty objects + +// changelog query helper +ConditionTemplateSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('condition_template', ConditionTemplateSchema); \ No newline at end of file diff --git a/src/models/material.ts b/src/models/material.ts new file mode 100644 index 0000000..d7d5eb9 --- /dev/null +++ b/src/models/material.ts @@ -0,0 +1,28 @@ +import mongoose from 'mongoose'; +import MaterialSupplierModel from '../models/material_suppliers'; +import MaterialGroupsModel from '../models/material_groups'; +import db from '../db'; + +const MaterialSchema = new mongoose.Schema({ + name: {type: String, index: {unique: true}}, + supplier_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialSupplierModel}, + group_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialGroupsModel}, + mineral: Number, + glass_fiber: Number, + carbon_fiber: Number, + numbers: [{ + color: String, + number: String + }], + status: Number +}, {minimize: false}); + +// changelog query helper +MaterialSchema.query.log = function > (req) { + db.log(req, this); + return this; +} +MaterialSchema.index({supplier_id: 1}); +MaterialSchema.index({group_id: 1}); + +export default mongoose.model>('material', MaterialSchema); \ No newline at end of file diff --git a/src/models/material_groups.ts b/src/models/material_groups.ts new file mode 100644 index 0000000..00be706 --- /dev/null +++ b/src/models/material_groups.ts @@ -0,0 +1,14 @@ +import mongoose from 'mongoose'; +import db from '../db'; + +const MaterialGroupsSchema = new mongoose.Schema({ + name: {type: String, index: {unique: true}} +}); + +// changelog query helper +MaterialGroupsSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('material_groups', MaterialGroupsSchema); \ No newline at end of file diff --git a/src/models/material_suppliers.ts b/src/models/material_suppliers.ts new file mode 100644 index 0000000..5c47e3b --- /dev/null +++ b/src/models/material_suppliers.ts @@ -0,0 +1,14 @@ +import mongoose from 'mongoose'; +import db from '../db'; + +const MaterialSuppliersSchema = new mongoose.Schema({ + name: {type: String, index: {unique: true}} +}); + +// changelog query helper +MaterialSuppliersSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('material_suppliers', MaterialSuppliersSchema); \ No newline at end of file diff --git a/src/models/measurement.ts b/src/models/measurement.ts new file mode 100644 index 0000000..55267ec --- /dev/null +++ b/src/models/measurement.ts @@ -0,0 +1,23 @@ +import mongoose from 'mongoose'; +import SampleModel from './sample'; +import MeasurementTemplateModel from './measurement_template'; +import db from '../db'; + + + +const MeasurementSchema = new mongoose.Schema({ + sample_id: {type: mongoose.Schema.Types.ObjectId, ref: SampleModel}, + values: mongoose.Schema.Types.Mixed, + measurement_template: {type: mongoose.Schema.Types.ObjectId, ref: MeasurementTemplateModel}, + status: Number +}, {minimize: false}); + +// changelog query helper +MeasurementSchema.query.log = function > (req) { + db.log(req, this); + return this; +} +MeasurementSchema.index({sample_id: 1}); +MeasurementSchema.index({measurement_template: 1}); + +export default mongoose.model>('measurement', MeasurementSchema); \ No newline at end of file diff --git a/src/models/measurement_template.ts b/src/models/measurement_template.ts new file mode 100644 index 0000000..b34e847 --- /dev/null +++ b/src/models/measurement_template.ts @@ -0,0 +1,20 @@ +import mongoose from 'mongoose'; +import db from '../db'; + +const MeasurementTemplateSchema = new mongoose.Schema({ + first_id: mongoose.Schema.Types.ObjectId, + name: String, + version: Number, + parameters: [new mongoose.Schema({ + name: String, + range: mongoose.Schema.Types.Mixed + } ,{ _id : false })] +}, {minimize: false}); // to allow empty objects + +// changelog query helper +MeasurementTemplateSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('measurement_template', MeasurementTemplateSchema); \ No newline at end of file diff --git a/src/models/note.ts b/src/models/note.ts new file mode 100644 index 0000000..5d02502 --- /dev/null +++ b/src/models/note.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; +import db from '../db'; + +const NoteSchema = new mongoose.Schema({ + comment: String, + sample_references: [{ + sample_id: mongoose.Schema.Types.ObjectId, + relation: String + }], + custom_fields: mongoose.Schema.Types.Mixed +}); + +// changelog query helper +NoteSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('note', NoteSchema); \ No newline at end of file diff --git a/src/models/note_field.ts b/src/models/note_field.ts new file mode 100644 index 0000000..733ba02 --- /dev/null +++ b/src/models/note_field.ts @@ -0,0 +1,15 @@ +import mongoose from 'mongoose'; +import db from '../db'; + +const NoteFieldSchema = new mongoose.Schema({ + name: {type: String, index: {unique: true}}, + qty: Number +}); + +// changelog query helper +NoteFieldSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('note_field', NoteFieldSchema); \ No newline at end of file diff --git a/src/models/sample.ts b/src/models/sample.ts new file mode 100644 index 0000000..8eec7bd --- /dev/null +++ b/src/models/sample.ts @@ -0,0 +1,29 @@ +import mongoose from 'mongoose'; + +import MaterialModel from './material'; +import NoteModel from './note'; +import UserModel from './user'; +import db from '../db'; + +const SampleSchema = new mongoose.Schema({ + number: {type: String, index: {unique: true}}, + type: String, + color: String, + batch: String, + condition: mongoose.Schema.Types.Mixed, + material_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialModel}, + note_id: {type: mongoose.Schema.Types.ObjectId, ref: NoteModel}, + user_id: {type: mongoose.Schema.Types.ObjectId, ref: UserModel}, + status: Number +}, {minimize: false}); + +// changelog query helper +SampleSchema.query.log = function > (req) { + db.log(req, this); + return this; +} +SampleSchema.index({material_id: 1}); +SampleSchema.index({note_id: 1}); +SampleSchema.index({user_id: 1}); + +export default mongoose.model>('sample', SampleSchema); \ No newline at end of file diff --git a/src/models/user.ts b/src/models/user.ts new file mode 100644 index 0000000..1e50d0c --- /dev/null +++ b/src/models/user.ts @@ -0,0 +1,20 @@ +import mongoose from 'mongoose'; +import db from '../db'; + +const UserSchema = new mongoose.Schema({ + name: {type: String, index: {unique: true}}, + email: String, + pass: String, + key: String, + level: String, + location: String, + device_name: String +}); + +// changelog query helper +UserSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('user', UserSchema); \ No newline at end of file diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts new file mode 100644 index 0000000..e412615 --- /dev/null +++ b/src/routes/material.spec.ts @@ -0,0 +1,1051 @@ +import should from 'should/as-function'; +import _ from 'lodash'; +import MaterialModel from '../models/material'; +import MaterialGroupModel from '../models/material_groups'; +import MaterialSupplierModel from '../models/material_suppliers'; +import TestHelper from "../test/helper"; +import globals from '../globals'; + + + +describe('/material', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); + + describe('GET /materials', () => { + it('returns all materials', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === globals.status.validated).length); + should(res.body).matchEach(material => { + should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); + should(material).have.property('_id').be.type('string'); + should(material).have.property('name').be.type('string'); + should(material).have.property('supplier').be.type('string'); + should(material).have.property('group').be.type('string'); + should(material).have.property('mineral').be.type('number'); + should(material).have.property('glass_fiber').be.type('number'); + should(material).have.property('carbon_fiber').be.type('number'); + should(material.numbers).matchEach(number => { + should(number).have.only.keys('color', 'number'); + should(number).have.property('color').be.type('string'); + should(number).have.property('number').be.type('string'); + }); + }); + done(); + }); + }); + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials', + auth: {key: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === globals.status.validated).length); + should(res.body).matchEach(material => { + should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); + should(material).have.property('_id').be.type('string'); + should(material).have.property('name').be.type('string'); + should(material).have.property('supplier').be.type('string'); + should(material).have.property('group').be.type('string'); + should(material).have.property('mineral').be.type('number'); + should(material).have.property('glass_fiber').be.type('number'); + should(material).have.property('carbon_fiber').be.type('number'); + should(material.numbers).matchEach(number => { + should(number).have.only.keys('color', 'number'); + should(number).have.property('color').be.type('string'); + should(number).have.property('number').be.type('string'); + }); + }); + done(); + }); + }); + it('allows filtering by state', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials?status=new', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === globals.status.new).length); + should(res.body).matchEach(material => { + should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); + should(material).have.property('_id').be.type('string'); + should(material).have.property('name').be.type('string'); + should(material).have.property('supplier').be.type('string'); + should(material).have.property('group').be.type('string'); + should(material).have.property('mineral').be.type('number'); + should(material).have.property('glass_fiber').be.type('number'); + should(material).have.property('carbon_fiber').be.type('number'); + should(material.numbers).matchEach(number => { + should(number).have.only.keys('color', 'number'); + should(number).have.property('color').be.type('string'); + should(number).have.property('number').be.type('string'); + }); + }); + done(); + }); + }); + it('rejects an invalid state name', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials?status=xxx', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"status" must be one of [validated, new, all]'} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials', + httpStatus: 401 + }); + }); + }); + + describe('GET /materials/{state}', () => { + it('returns all new materials', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials/new', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + let asyncCounter = res.body.length; + should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status ===globals.status.new).length); + should(res.body).matchEach(material => { + should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); + should(material).have.property('_id').be.type('string'); + should(material).have.property('name').be.type('string'); + should(material).have.property('supplier').be.type('string'); + should(material).have.property('group').be.type('string'); + should(material).have.property('mineral').be.type('number'); + should(material).have.property('glass_fiber').be.type('number'); + should(material).have.property('carbon_fiber').be.type('number'); + should(material.numbers).matchEach(number => { + should(number).have.only.keys('color', 'number'); + should(number).have.property('color').be.type('string'); + should(number).have.property('number').be.type('string'); + }); + MaterialModel.findById(material._id).lean().exec((err, data) => { + should(data).have.property('status',globals.status.new); + if (--asyncCounter === 0) { + done(); + } + }); + }); + }); + }); + it('returns all deleted materials', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials/deleted', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + let asyncCounter = res.body.length; + should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status ===globals.status.deleted).length); + should(res.body).matchEach(material => { + should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); + should(material).have.property('_id').be.type('string'); + should(material).have.property('name').be.type('string'); + should(material).have.property('supplier').be.type('string'); + should(material).have.property('group').be.type('string'); + should(material).have.property('mineral').be.type('number'); + should(material).have.property('glass_fiber').be.type('number'); + should(material).have.property('carbon_fiber').be.type('number'); + should(material.numbers).matchEach(number => { + should(number).have.only.keys('color', 'number'); + should(number).have.property('color').be.type('string'); + should(number).have.property('number').be.type('string'); + }); + MaterialModel.findById(material._id).lean().exec((err, data) => { + should(data).have.property('status',globals.status.deleted); + if (--asyncCounter === 0) { + done(); + } + }); + }); + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials/new', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials/deleted', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials/new', + httpStatus: 401 + }); + }); + }); + + describe('GET /material/{id}', () => { + it('returns the right material', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]} + }); + }); + it('returns the right material for an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000003', + auth: {key: 'admin'}, + httpStatus: 200, + res: {_id: '100000000000000000000003', name: 'PA GF 50 black (2706)', supplier: 'Akro-Plastic', group: 'PA66+PA6I/6T', mineral: 0, glass_fiber: 0, carbon_fiber: 0, numbers: []} + }); + }); + it('returns a material with a color without number', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000007', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '100000000000000000000007', name: 'Ultramid A4H', supplier: 'BASF', group: 'PA66', mineral: 0, glass_fiber: 0, carbon_fiber: 0, numbers: [{color: 'black', number: ''}]} + }); + }); + it('returns a deleted material for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000008', + auth: {basic: 'admin'}, + httpStatus: 200, + res: {_id: '100000000000000000000008', name: 'Latamid 66 H 2 G 30', supplier: 'LATI', group: 'PA66', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'blue', number: '5513943509'}]} + }); + }); + it('returns 403 for a write user when requesting a deleted material', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000008', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/10000000000000000000000x', + auth: {key: 'admin'}, + httpStatus: 404 + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000111', + auth: {key: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000001', + httpStatus: 401 + }); + }); + }); + + describe('PUT /material/{id}', () => { + it('returns the right material', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {}, + res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]} + }); + }); + it('keeps unchanged properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]}); + MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('status',globals.status.validated); + MaterialGroupModel.find({name: 'PA46'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]._id.toString()).be.eql('900000000000000000000001'); + MaterialSupplierModel.find({name: 'DSM'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]._id.toString()).be.eql('110000000000000000000001'); + done(); + }); + }); + }); + }); + }); + it('keeps only one unchanged property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'Stanyl TW 200 F8'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]}); + MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('status',globals.status.validated); + done(); + }); + }); + }); + it('changes the given properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}]} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}]}); + MaterialModel.findById('100000000000000000000001').lean().exec((err, data:any) => { + if (err) return done(err); + data._id = data._id.toString(); + data.group_id = data.group_id.toString(); + data.supplier_id = data.supplier_id.toString(); + data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}}); + should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier_id: '110000000000000000000002', group_id: '900000000000000000000002', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}], status: 0, __v: 0}); + MaterialGroupModel.find({name: 'PA6/6T'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]._id.toString()).be.eql('900000000000000000000002'); + MaterialSupplierModel.find({name: 'BASF'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]._id.toString()).be.eql('110000000000000000000002'); + done(); + }); + }); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}]}, + log: { + collection: 'materials', + dataAdd: { + group_id: '900000000000000000000002', + supplier_id: '110000000000000000000002', + status: 0 + }, + dataIgn: ['supplier', 'group'] + } + }); + }); + it('accepts a color without number', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000007', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {numbers: [{color: 'black', number: ''}, {color: 'natural', number: ''}]}, + res: {_id: '100000000000000000000007', name: 'Ultramid A4H', supplier: 'BASF', group: 'PA66', mineral: 0, glass_fiber: 0, carbon_fiber: 0, numbers: [{color: 'black', number: ''}, {color: 'natural', number: ''}]} + }); + }) + it('rejects already existing material names', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Ultramid T KR 4355 G7'}, + res: {status: 'Material name already taken'} + }); + }); + it('rejects a wrong mineral property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {mineral: 'x'}, + res: {status: 'Invalid body format', details: '"mineral" must be a number'} + }); + }); + it('rejects a wrong glass_fiber property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {glass_fiber: 'x'}, + res: {status: 'Invalid body format', details: '"glass_fiber" must be a number'} + }); + }); + it('rejects a wrong carbon_fiber property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {carbon_fiber: 'x'}, + res: {status: 'Invalid body format', details: '"carbon_fiber" must be a number'} + }); + }); + it('rejects a wrong color name property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {numbers: [{colorxx: 'black', number: '55'}]}, + res: {status: 'Invalid body format', details: '"numbers[0].color" is required'} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/10000000000000000000000x', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {}, + }); + }); + it('rejects editing a deleted material', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000008', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000002', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000002', + auth: {basic: 'user'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown material', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000111', + auth: {basic: 'janedoe'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('DELETE /material/{id}', () => { + it('sets the status to deleted', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + MaterialModel.findById('100000000000000000000002').lean().exec((err, data: any) => { + if (err) return done(err); + data._id = data._id.toString(); + data.group_id = data.group_id.toString(); + data.supplier_id = data.supplier_id.toString(); + data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}}); + should(data).be.eql({_id: '100000000000000000000002', name: 'Ultramid T KR 4355 G7', supplier_id: '110000000000000000000002', group_id: '900000000000000000000002', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}], status: -1, __v: 0} + ); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200, + log: { + collection: 'materials', + dataAdd: { status: -1} + } + }); + }); + it('rejects deleting a material referenced by samples', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Material still in use'} + }) + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/10000000000000000000000x', + auth: {basic: 'admin'}, + httpStatus: 404 + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000002', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000002', + auth: {basic: 'user'}, + httpStatus: 403 + }); + }); + it('returns 404 for an unknown id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000111', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000001', + httpStatus: 401 + }); + }); + }); + + describe('PUT /material/restore/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + MaterialModel.findById('100000000000000000000008').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.new); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'materials', + dataAdd: { + status: 0 + } + } + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/000000000000000000000008', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('PUT /material/validate/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/validate/100000000000000000000007', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + MaterialModel.findById('100000000000000000000007').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.validated); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/validate/100000000000000000000007', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'materials', + dataAdd: { + status: 10 + } + } + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/validate/100000000000000000000007', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/validate/100000000000000000000007', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/validate/000000000000000000000007', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/validate/100000000000000000000007', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('POST /material/new', () => { + it('returns the right material', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '05515798402'}]} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('name', 'Crastin CE 2510'); + should(res.body).have.property('supplier', 'Du Pont'); + should(res.body).have.property('group', 'PBT'); + should(res.body).have.property('mineral', 0); + should(res.body).have.property('glass_fiber', 30); + should(res.body).have.property('carbon_fiber', 0); + should(res.body.numbers).matchEach(number => { + should(number).have.only.keys('color', 'number'); + should(number).have.property('color', 'black'); + should(number).have.property('number', '05515798402'); + }); + done(); + }); + }); + it('stores the material', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []} + }).end(err => { + if (err) return done (err); + MaterialModel.find({name: 'Crastin CE 2510'}).lean().exec((err, materialData: any) => { + if (err) return done (err); + should(materialData).have.lengthOf(1); + should(materialData[0]).have.only.keys('_id', 'name', 'supplier_id', 'group_id', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', 'status', '__v'); + should(materialData[0]).have.property('name', 'Crastin CE 2510'); + should(materialData[0]).have.property('mineral', 0); + should(materialData[0]).have.property('glass_fiber', 30); + should(materialData[0]).have.property('carbon_fiber', 0); + should(materialData[0]).have.property('status',globals.status.new); + should(materialData[0].numbers).have.lengthOf(0); + MaterialGroupModel.findById(materialData[0].group_id).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('name', 'PBT') + MaterialSupplierModel.findById(materialData[0].supplier_id).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('name', 'Du Pont'); + done(); + }); + }); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []}, + log: { + collection: 'materials', + dataAdd: {status: 0}, + dataIgn: ['group_id', 'supplier_id', 'group', 'supplier'] + } + }); + }); + it('accepts a color without number', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: ''}]} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('name', 'Crastin CE 2510'); + should(res.body).have.property('supplier', 'Du Pont'); + should(res.body).have.property('group', 'PBT'); + should(res.body).have.property('mineral', 0); + should(res.body).have.property('glass_fiber', 30); + should(res.body).have.property('carbon_fiber', 0); + should(res.body.numbers).matchEach(number => { + should(number).have.only.keys('color', 'number'); + should(number).have.property('color', 'black'); + should(number).have.property('number', ''); + }); + MaterialModel.find({name: 'Crastin CE 2510'}).lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'supplier_id', 'group_id', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', 'status', '__v'); + should(data[0]).have.property('_id'); + should(data[0]).have.property('name', 'Crastin CE 2510'); + should(data[0]).have.property('mineral', 0); + should(data[0]).have.property('glass_fiber', 30); + should(data[0]).have.property('carbon_fiber', 0); + should(data[0]).have.property('status',globals.status.new); + should(_.omit(data[0].numbers[0], '_id')).be.eql({color: 'black', number: ''}); + done(); + }); + }); + }); + it('rejects already existing material names', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}]}, + res: {status: 'Material name already taken'} + }); + }); + it('rejects a missing name', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]}, + res: {status: 'Invalid body format', details: '"name" is required'} + }); + }); + it('rejects a missing supplier', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]}, + res: {status: 'Invalid body format', details: '"supplier" is required'} + }); + }); + it('rejects a missing group', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]}, + res: {status: 'Invalid body format', details: '"group" is required'} + }); + }); + it('rejects a missing mineral property', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]}, + res: {status: 'Invalid body format', details: '"mineral" is required'} + }); + }); + it('rejects a missing glass_fiber property', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]}, + res: {status: 'Invalid body format', details: '"glass_fiber" is required'} + }); + }); + it('rejects a missing carbon_fiber property', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, numbers: [{color: 'black', number: '5515798402'}]}, + res: {status: 'Invalid body format', details: '"carbon_fiber" is required'} + }); + }); + it('rejects a missing numbers array', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0}, + res: {status: 'Invalid body format', details: '"numbers" is required'} + }); + }); + it('rejects a missing color name', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{number: '5515798402'}]}, + res: {status: 'Invalid body format', details: '"numbers[0].color" is required'} + }); + }); + it('rejects a missing color number', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black'}]}, + res: {status: 'Invalid body format', details: '"numbers[0].number" is required'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []} + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'user'}, + httpStatus: 403, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + httpStatus: 401, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []} + }); + }); + }); + + describe('GET /material/groups', () => { + it('returns all groups', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/groups', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.material_groups.length); + should(res.body[0]).be.eql(json.collections.material_groups[0].name); + should(res.body).matchEach(group => { + should(group).be.type('string'); + }); + done(); + }); + }); + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/groups', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.material_groups.length); + should(res.body[0]).be.eql(json.collections.material_groups[0].name); + should(res.body).matchEach(group => { + should(group).be.type('string'); + }); + done(); + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/groups', + httpStatus: 401 + }); + }); + }); + + describe('GET /material/suppliers', () => { + it('returns all suppliers', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/suppliers', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.material_suppliers.length); + should(res.body[0]).be.eql(json.collections.material_suppliers[0].name); + should(res.body).matchEach(supplier => { + should(supplier).be.type('string'); + }); + done(); + }); + }); + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/suppliers', + auth: {key: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.material_suppliers.length); + should(res.body[0]).be.eql(json.collections.material_suppliers[0].name); + should(res.body).matchEach(supplier => { + should(supplier).be.type('string'); + }); + done(); + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/suppliers', + httpStatus: 401 + }); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/material.ts b/src/routes/material.ts new file mode 100644 index 0000000..3f34e3a --- /dev/null +++ b/src/routes/material.ts @@ -0,0 +1,223 @@ +import express from 'express'; +import _ from 'lodash'; + +import MaterialValidate from './validate/material'; +import MaterialModel from '../models/material' +import SampleModel from '../models/sample'; +import MaterialGroupModel from '../models/material_groups'; +import MaterialSupplierModel from '../models/material_suppliers'; +import IdValidate from './validate/id'; +import res400 from './validate/res400'; +import mongoose from 'mongoose'; +import globals from '../globals'; +import db from '../db'; + + + +const router = express.Router(); + +router.get('/materials', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + const {error, value: filters} = MaterialValidate.query(req.query); + if (error) return res400(error, res); + + let conditions; + + if (filters.hasOwnProperty('status')) { + if(filters.status === 'all') { + conditions = {$or: [{status: globals.status.validated}, {status: globals.status.new}]} + } + else { + conditions = {status: globals.status[filters.status]}; + } + } + else { // default + conditions = {status: globals.status.validated}; + } + + MaterialModel.find(conditions).populate('group_id').populate('supplier_id').lean().exec((err, data) => { + if (err) return next(err); + + res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors + }); +}); + +router.get('/materials/:state(new|deleted)', (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + MaterialModel.find({status: globals.status[req.params.state]}).populate('group_id').populate('supplier_id').lean().exec((err, data) => { + if (err) return next(err); + + res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors + }); +}); + +router.get('/material/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + MaterialModel.findById(req.params.id).populate('group_id').populate('supplier_id').lean().exec((err, data: any) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + + if (data.status === globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted materials only available for maintain/admin + res.json(MaterialValidate.output(data)); + }); +}); + +router.put('/material/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + let {error, value: material} = MaterialValidate.input(req.body, 'change'); + if (error) return res400(error, res); + + MaterialModel.findById(req.params.id).lean().exec(async (err, materialData: any) => { + if (!materialData) { + return res.status(404).json({status: 'Not found'}); + } + if (materialData.status === globals.status.deleted) { + return res.status(403).json({status: 'Forbidden'}); + } + if (material.hasOwnProperty('name') && material.name !== materialData.name) { + if (!await nameCheck(material, res, next)) return; + } + if (material.hasOwnProperty('group')) { + material = await groupResolve(material, req, next); + if (!material) return; + } + if (material.hasOwnProperty('supplier')) { + material = await supplierResolve(material, req, next); + if (!material) return; + } + + // check for changes + if (!_.isEqual(_.pick(IdValidate.stringify(materialData), _.keys(material)), IdValidate.stringify(material))) { + material.status = globals.status.new; // set status to new + } + + await MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).log(req).populate('group_id').populate('supplier_id').lean().exec((err, data) => { + if (err) return next(err); + res.json(MaterialValidate.output(data)); + }); + }); +}); + +router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + // check if there are still samples referencing this material + SampleModel.find({'material_id': new mongoose.Types.ObjectId(req.params.id)}).lean().exec((err, data) => { + if (err) return next(err); + if (data.length) { + return res.status(400).json({status: 'Material still in use'}); + } + MaterialModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).log(req).populate('group_id').populate('supplier_id').lean().exec((err, data) => { + if (err) return next(err); + if (data) { + res.json({status: 'OK'}); + } + else { + res.status(404).json({status: 'Not found'}); + } + }); + }); +}); + +router.put('/material/restore/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + setStatus(globals.status.new, req, res, next); +}); + +router.put('/material/validate/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + setStatus(globals.status.validated, req, res, next); +}); + +router.post('/material/new', async (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + let {error, value: material} = MaterialValidate.input(req.body, 'new'); + if (error) return res400(error, res); + + if (!await nameCheck(material, res, next)) return; + material = await groupResolve(material, req, next); + if (!material) return; + material = await supplierResolve(material, req, next); + if (!material) return; + + + material.status = globals.status.new; // set status to new + await new MaterialModel(material).save(async (err, data) => { + if (err) return next(err); + db.log(req, 'materials', {_id: data._id}, data.toObject()); + await data.populate('group_id').populate('supplier_id').execPopulate().catch(err => next(err)); + if (data instanceof Error) return; + res.json(MaterialValidate.output(data.toObject())); + }); +}); + +router.get('/material/groups', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + MaterialGroupModel.find().lean().exec((err, data: any) => { + if (err) return next(err); + + res.json(_.compact(data.map(e => MaterialValidate.outputGroups(e.name)))); // validate all and filter null values from validation errors + }); +}); + +router.get('/material/suppliers', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + MaterialSupplierModel.find().lean().exec((err, data: any) => { + if (err) return next(err); + + res.json(_.compact(data.map(e => MaterialValidate.outputSuppliers(e.name)))); // validate all and filter null values from validation errors + }); +}); + + +module.exports = router; + + +async function nameCheck (material, res, next) { // check if name was already taken + const materialData = await MaterialModel.findOne({name: material.name}).lean().exec().catch(err => next(err)) as any; + if (materialData instanceof Error) return false; + if (materialData) { // could not find material_id + res.status(400).json({status: 'Material name already taken'}); + return false; + } + return true; +} + +async function groupResolve (material, req, next) { + const groupData = await MaterialGroupModel.findOneAndUpdate({name: material.group}, {name: material.group}, {upsert: true, new: true}).log(req).lean().exec().catch(err => next(err)) as any; + if (groupData instanceof Error) return false; + material.group_id = groupData._id; + delete material.group; + return material; +} + +async function supplierResolve (material, req, next) { + const supplierData = await MaterialSupplierModel.findOneAndUpdate({name: material.supplier}, {name: material.supplier}, {upsert: true, new: true}).log(req).lean().exec().catch(err => next(err)) as any; + if (supplierData instanceof Error) return false; + material.supplier_id = supplierData._id; + delete material.supplier; + return material; +} + +function setStatus (status, req, res, next) { // set measurement status + MaterialModel.findByIdAndUpdate(req.params.id, {status: status}).log(req).lean().exec((err, data) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + res.json({status: 'OK'}); + }); +} \ No newline at end of file diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts new file mode 100644 index 0000000..dd43520 --- /dev/null +++ b/src/routes/measurement.spec.ts @@ -0,0 +1,790 @@ +import should from 'should/as-function'; +import MeasurementModel from '../models/measurement'; +import TestHelper from "../test/helper"; +import globals from '../globals'; + + +describe('/measurement', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); + + describe('GET /measurement/{id}', () => { + it('returns the right measurement', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '800000000000000000000001', sample_id: '400000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'} + }); + }); + it('returns the measurement for an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/800000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 200, + res: {_id: '800000000000000000000001', sample_id: '400000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'} + }); + }); + it('returns deleted measurements for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/800000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + res: {_id: '800000000000000000000004', sample_id: '400000000000000000000003', values: {val1: 1}, measurement_template: '300000000000000000000003'} + }); + }); + it('rejects requests for deleted measurements from a write user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/800000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/8000000000h0000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/000000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/800000000000000000000001', + httpStatus: 401 + }); + }); + }); + + describe('PUT /measurement/{id}', () => { + it('returns the right measurement', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {}, + res: {_id: '800000000000000000000001', sample_id: '400000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'} + }); + }); + it('keeps unchanged values', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '800000000000000000000001', sample_id: '400000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'}); + MeasurementModel.findById('800000000000000000000001').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.validated); + done(); + }); + }); + }); + it('keeps only one unchanged value', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {values: {'weight %': 0.5}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '800000000000000000000002', sample_id: '400000000000000000000002', values: {'weight %': 0.5, 'standard deviation': 0.2}, measurement_template: '300000000000000000000002'}); + MeasurementModel.findById('800000000000000000000002').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.validated); + done(); + }); + }); + }); + it('changes the given values', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {values: {dpt: [[1,2],[3,4],[5,6]]}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '800000000000000000000001', sample_id: '400000000000000000000001', values: {dpt: [[1,2],[3,4],[5,6]]}, measurement_template: '300000000000000000000001'}); + MeasurementModel.findById('800000000000000000000001').lean().exec((err, data: any) => { + should(data).have.only.keys('_id', 'sample_id', 'values', 'measurement_template', 'status', '__v'); + should(data.sample_id.toString()).be.eql('400000000000000000000001'); + should(data.measurement_template.toString()).be.eql('300000000000000000000001'); + should(data).have.property('status',globals.status.new); + should(data).have.property('values'); + should(data.values).have.property('dpt', [[1,2],[3,4],[5,6]]); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {values: {dpt: [[1,2],[3,4],[5,6]]}}, + log: { + collection: 'measurements', + dataAdd: { + measurement_template: '300000000000000000000001', + sample_id: '400000000000000000000001', + status: 0 + } + } + }); + }); + it('allows changing only one value', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {values: {'weight %': 0.9}}, + res: {_id: '800000000000000000000002', sample_id: '400000000000000000000002', values: {'weight %': 0.9, 'standard deviation': 0.2}, measurement_template: '300000000000000000000002'} + }); + }); + it('allows keeping empty values empty', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000005', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {values: {'weight %': 0.9}}, + res: {_id: '800000000000000000000005', sample_id: '400000000000000000000002', values: {'weight %': 0.9, 'standard deviation': null}, measurement_template: '300000000000000000000002'} + }); + }); + it('rejects not specified values', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {values: {'weight %': 0.9, 'standard deviation': 0.3, xx: 44}}, + res: {status: 'Invalid body format', details: '"xx" is not allowed'} + }); + }); + it('rejects a value not in the value range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {values: {val1: 4}}, + res: {status: 'Invalid body format', details: '"val1" must be one of [1, 2, 3, null]'} + }); + }); + it('rejects a value below minimum range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {values: {'weight %': -1, 'standard deviation': 0.3}}, + res: {status: 'Invalid body format', details: '"weight %" must be larger than or equal to 0'} + }); + }); + it('rejects a value above maximum range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {values: {'weight %': 0.9, 'standard deviation': 3}}, + res: {status: 'Invalid body format', details: '"standard deviation" must be less than or equal to 0.5'} + }); + }); + it('rejects a new measurement template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {values: {'weight %': 0.9, 'standard deviation': 0.3}, measurement_template: '300000000000000000000001'}, + res: {status: 'Invalid body format', details: '"measurement_template" is not allowed'} + }); + }); + it('rejects a new sample id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {values: {'weight %': 0.9, 'standard deviation': 0.3}, sample_id: '400000000000000000000002'}, + res: {status: 'Invalid body format', details: '"sample_id" is not allowed'} + }); + }); + it('rejects editing a measurement for a write user who did not create this measurement', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {values: {val1: 2}} + }); + }); + it('accepts editing a measurement of another user for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {values: {'weight %': 0.9, 'standard deviation': 0.3}}, + res: {_id: '800000000000000000000002', sample_id: '400000000000000000000002', values: {'weight %': 0.9, 'standard deviation': 0.3}, measurement_template: '300000000000000000000002'} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000h00000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/000000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects editing a deleted measurement', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 403, + req: {} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {values: {'weight %': 0.9, 'standard deviation': 0.3}}, + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'user'}, + httpStatus: 403, + req: {values: {'weight %': 0.9, 'standard deviation': 0.3}}, + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + httpStatus: 401, + req: {values: {'weight %': 0.9, 'standard deviation': 0.3}}, + }); + }); + }); + + describe('DELETE /measurement/{id}', () => { + it('sets the status to deleted', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + MeasurementModel.findById('800000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('status',globals.status.deleted); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + log: { + collection: 'measurements', + dataAdd: { + status: -1 + } + } + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 401, + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000001', + auth: {basic: 'user'}, + httpStatus: 403, + }); + }); + it('rejects deleting a measurement for a write user who did not create this measurement', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 403, + }); + }); + it('accepts deleting a measurement of another user for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + res: {status: 'OK'} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000h00000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404, + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/000000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404, + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000001', + httpStatus: 401, + }); + }); + }); + + describe('PUT /measurement/restore/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + MeasurementModel.findById('800000000000000000000004').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.new); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'measurements', + dataAdd: { + status: 0 + } + } + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/000000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('PUT /measurement/validate/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/validate/800000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + MeasurementModel.findById('800000000000000000000003').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.validated); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/validate/800000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'measurements', + dataAdd: { + status: 10 + } + } + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/validate/800000000000000000000003', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/validate/800000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/validate/000000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/validate/800000000000000000000003', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('POST /measurement/new', () => { + it('returns the right measurement', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'sample_id', 'values', 'measurement_template'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('sample_id', '400000000000000000000001'); + should(res.body).have.property('measurement_template', '300000000000000000000002'); + should(res.body).have.property('values'); + should(res.body.values).have.property('weight %', 0.8); + should(res.body.values).have.property('standard deviation', 0.1); + done(); + }); + }); + it('stores the measurement', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }).end((err, res) => { + if (err) return done(err); + MeasurementModel.findById(res.body._id).lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.only.keys('_id', 'sample_id', 'values', 'measurement_template', 'status', '__v'); + should(data.sample_id.toString()).be.eql('400000000000000000000001'); + should(data.measurement_template.toString()).be.eql('300000000000000000000002'); + should(data).have.property('status', 0); + should(data).have.property('values'); + should(data.values).have.property('weight %', 0.8); + should(data.values).have.property('standard deviation', 0.1); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + log: { + collection: 'measurements', + dataAdd: { + status: 0 + } + } + }); + }); + it('rejects an invalid sample id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000h00000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"sample_id" with value "400000000000h00000000001" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects a sample id not available', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '000000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + res: {status: 'Sample id not available'} + }); + }); + it('rejects an invalid measurement_template id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '30000000000h000000000002'}, + res: {status: 'Invalid body format', details: '"measurement_template" with value "30000000000h000000000002" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects a measurement_template not available', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '000000000000000000000002'}, + res: {status: 'Measurement template not available'} + }); + }); + it('rejects not specified values', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1, xx: 44}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"xx" is not allowed'} + }); + }); + it('accepts missing values', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8}, measurement_template: '300000000000000000000002'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'sample_id', 'values', 'measurement_template'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('sample_id', '400000000000000000000001'); + should(res.body).have.property('measurement_template', '300000000000000000000002'); + should(res.body).have.property('values'); + should(res.body.values).have.property('weight %', 0.8); + should(res.body.values).have.property('standard deviation', null); + done(); + }); + }); + it('rejects no values', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000001', values: {}, measurement_template: '300000000000000000000002'}, + res: {status: 'At least one value is required'} + }); + }); + it('rejects a value not in the value range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000001', values: {val2: 5}, measurement_template: '300000000000000000000004'}, + res: {status: 'Invalid body format', details: '"val2" must be one of [1, 2, 3, 4, null]'} + }); + }); + it('rejects a value below minimum range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000001', values: {'weight %': -1, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"weight %" must be larger than or equal to 0'} + }); + }); + it('rejects a value above maximum range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 2}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"standard deviation" must be less than or equal to 0.5'} + }); + }); + it('rejects a missing sample id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"sample_id" is required'} + }); + }); + it('rejects a missing measurement_template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}}, + res: {status: 'Invalid body format', details: '"measurement_template" is required'} + }); + }); + it('rejects adding a measurement to the sample of another user for a write user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {sample_id: '400000000000000000000003', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }); + }); + it('accepts adding a measurement to the sample of another user for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'sample_id', 'values', 'measurement_template'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('sample_id', '400000000000000000000001'); + should(res.body).have.property('measurement_template', '300000000000000000000002'); + should(res.body).have.property('values'); + should(res.body.values).have.property('weight %', 0.8); + should(res.body.values).have.property('standard deviation', 0.1); + done(); + }); + }); + it('rejects an old version of a measurement template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000001', values: {val1: 2}, measurement_template: '300000000000000000000003'}, + res: {status: 'Old template version not allowed'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'user'}, + httpStatus: 403, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + httpStatus: 401, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts new file mode 100644 index 0000000..47af305 --- /dev/null +++ b/src/routes/measurement.ts @@ -0,0 +1,169 @@ +import express from 'express'; +import _ from 'lodash'; + +import MeasurementModel from '../models/measurement'; +import MeasurementTemplateModel from '../models/measurement_template'; +import SampleModel from '../models/sample'; +import MeasurementValidate from './validate/measurement'; +import IdValidate from './validate/id'; +import res400 from './validate/res400'; +import ParametersValidate from './validate/parameters'; +import globals from '../globals'; +import db from '../db'; + + +const router = express.Router(); + +router.get('/measurement/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + MeasurementModel.findById(req.params.id).lean().exec((err, data: any) => { + if (err) return next(err); + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + if (data.status ===globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted measurements only available for maintain/admin + + res.json(MeasurementValidate.output(data)); + }); +}); + +router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const {error, value: measurement} = MeasurementValidate.input(req.body, 'change'); + if (error) return res400(error, res); + + const data = await MeasurementModel.findById(req.params.id).lean().exec().catch(err => {next(err);}) as any; + if (data instanceof Error) return; + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + if (data.status === globals.status.deleted) { + return res.status(403).json({status: 'Forbidden'}); + } + + // add properties needed for sampleIdCheck + measurement.measurement_template = data.measurement_template; + measurement.sample_id = data.sample_id; + if (!await sampleIdCheck(measurement, req, res, next)) return; + + // check for changes + if (measurement.values) { // fill not changed values from database + measurement.values = _.assign({}, data.values, measurement.values); + if (!_.isEqual(measurement.values, data.values)) { + measurement.status = globals.status.new; // set status to new + } + } + + if (!await templateCheck(measurement, 'change', res, next)) return; + await MeasurementModel.findByIdAndUpdate(req.params.id, measurement, {new: true}).log(req).lean().exec((err, data) => { + if (err) return next(err); + res.json(MeasurementValidate.output(data)); + }); +}); + +router.delete('/measurement/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + MeasurementModel.findById(req.params.id).lean().exec(async (err, data) => { + if (err) return next(err); + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + if (!await sampleIdCheck(data, req, res, next)) return; + await MeasurementModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).log(req).lean().exec(err => { + if (err) return next(err); + return res.json({status: 'OK'}); + }); + }); +}); + +router.put('/measurement/restore/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + setStatus(globals.status.new, req, res, next); +}); + +router.put('/measurement/validate/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + setStatus(globals.status.validated, req, res, next); +}); + +router.post('/measurement/new', async (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const {error, value: measurement} = MeasurementValidate.input(req.body, 'new'); + if (error) return res400(error, res); + + if (!await sampleIdCheck(measurement, req, res, next)) return; + measurement.values = await templateCheck(measurement, 'new', res, next); + if (!measurement.values) return; + + measurement.status = 0; + await new MeasurementModel(measurement).save((err, data) => { + if (err) return next(err); + db.log(req, 'measurements', {_id: data._id}, data.toObject()); + res.json(MeasurementValidate.output(data.toObject())); + }); +}); + + +module.exports = router; + + +async function sampleIdCheck (measurement, req, res, next) { // validate sample_id, returns false if invalid or user has no access for this sample + const sampleData = await SampleModel.findById(measurement.sample_id).lean().exec().catch(err => {next(err); return false;}) as any; + if (!sampleData) { // sample_id not found + res.status(400).json({status: 'Sample id not available'}); + return false + } + if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return false; // sample does not belong to user + return true; +} + +async function templateCheck (measurement, param, res, next) { // validate measurement_template and values, returns values, true if values are {} or false if invalid, param for 'new'/'change' + const templateData = await MeasurementTemplateModel.findById(measurement.measurement_template).lean().exec().catch(err => {next(err); return false;}) as any; + if (!templateData) { // template not found + res.status(400).json({status: 'Measurement template not available'}); + return false + } + + // fill not given values for new measurements + if (param === 'new') { + // get all template versions and check if given is latest + const templateVersions = await MeasurementTemplateModel.find({first_id: templateData.first_id}).sort({version: -1}).lean().exec().catch(err => next(err)) as any; + if (templateVersions instanceof Error) return false; + if (measurement.measurement_template !== templateVersions[0]._id.toString()) { // template not latest + res.status(400).json({status: 'Old template version not allowed'}); + return false; + } + + if (Object.keys(measurement.values).length === 0) { + res.status(400).json({status: 'At least one value is required'}); + return false + } + const fillValues = {}; // initialize not given values with null + templateData.parameters.forEach(parameter => { + fillValues[parameter.name] = null; + }); + measurement.values = _.assign({}, fillValues, measurement.values); + } + + // validate values + const {error, value} = ParametersValidate.input(measurement.values, templateData.parameters, 'null'); + if (error) {res400(error, res); return false;} + return value || true; +} + +function setStatus (status, req, res, next) { // set measurement status + MeasurementModel.findByIdAndUpdate(req.params.id, {status: status}).log(req).lean().exec((err, data) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + res.json({status: 'OK'}); + }); +} \ No newline at end of file diff --git a/src/routes/root.spec.ts b/src/routes/root.spec.ts index cfec79c..68531a5 100644 --- a/src/routes/root.spec.ts +++ b/src/routes/root.spec.ts @@ -1,19 +1,256 @@ -import supertest from 'supertest'; +import TestHelper from "../test/helper"; import should from 'should/as-function'; +import db from '../db'; -let server = supertest.agent('http://localhost:3000'); +describe('/', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); -describe('Testing /', () => { - it('returns the message object', done => { - server - .get('/') - .expect('Content-type', /json/) - .expect(200) - .end(function(err, res) { - should(res.statusCode).equal(200); - should(res.body).be.eql({message: 'API server up and running!'}); + describe('GET /', () => { + it('returns the root message', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/', + httpStatus: 200, + res: {status: 'API server up and running!'} + }); + }); + }); + + describe('GET /changelog/{timestamp}/{page}/{pagesize}', () => { + it('returns the first page', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/0/2', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(2); + should(res.body[0].date).be.eql('1979-07-28T06:04:51.000Z'); + should(res.body[1].date).be.eql('1979-07-28T06:04:50.000Z'); + should(res.body).matchEach(log => { + should(log).have.only.keys('date', 'action', 'collection', 'conditions', 'data'); + should(log).have.property('action', 'PUT /sample/400000000000000000000001'); + should(log).have.property('collection', 'samples'); + should(log).have.property('conditions', {_id: '400000000000000000000001'}); + should(log).have.property('data', {type: 'part', status: 0}); + }); done(); }); + }); + it('returns another page', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/1/2', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(1); + should(res.body[0].date).be.eql('1979-07-28T06:04:49.000Z'); + should(res.body).matchEach(log => { + should(log).have.only.keys('date', 'action', 'collection', 'conditions', 'data'); + should(log).have.property('action', 'PUT /sample/400000000000000000000001'); + should(log).have.property('collection', 'samples'); + should(log).have.property('conditions', {_id: '400000000000000000000001'}); + should(log).have.property('data', {type: 'part', status: 0}); + done(); + }); + }); + }); + it('returns an empty array for a page with no results', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/2', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(0); + done(); + }); + }); + it('rejects timestamps pre unix epoch', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1879-07-28T06:04:51.000Z/10/2', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"timestamp" must be larger than or equal to "1970-01-01T00:00:00.000Z"'} + }); + }); + it('rejects invalid timestamps', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-14-28T06:04:51.000Z/10/2', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"timestamp" must be in ISO 8601 date format'} + }); + }); + it('rejects negative page numbers', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/-10/2', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"page" must be larger than or equal to 0'} + }); + }); + it('rejects negative pagesizes', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/-2', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"pagesize" must be larger than or equal to 0'} + }); + }); + it('rejects request from a write user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/2', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects requests from an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/2', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/2', + httpStatus: 401 + }); + }); + }); + + describe('Unknown routes', () => { + it('return a 404 message', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/unknownroute', + httpStatus: 404 + }); + }); + }); + + describe('An unauthorized request', () => { + it('returns a 401 message', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/authorized', + httpStatus: 401 + }); + }); + it('does not work with correct username', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/authorized', + auth: {basic: {name: 'admin', pass: 'Abc123!!'}}, + httpStatus: 401 + }); + }); + it('does not work with incorrect username', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/authorized', + auth: {basic: {name: 'adminxx', pass: 'Abc123!!'}}, + httpStatus: 401 + }); + }); + }); + + describe('An authorized request', () => { + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/authorized', + auth: {key: 'admin'}, + httpStatus: 200, + res: {status: 'Authorization successful', method: 'key'} + }); + }); + it('works with basic auth', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/authorized', + auth: {basic: 'admin'}, + httpStatus: 200, + res: {status: 'Authorization successful', method: 'basic'} + }); + }); + }); + + describe('An invalid JSON body', () => { + it('is rejected', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/', + httpStatus: 400, + reqType: 'json', + req: '{"xxx"}', + res: {status: 'Invalid JSON body'} + }); + + }); + }); + + describe('A not connected database', () => { // RUN AS LAST OR RECONNECT DATABASE!! + it('resolves to an 500 error', done => { + db.disconnect(() => { + TestHelper.request(server, done, { + method: 'get', + url: '/', + httpStatus: 500 + }); + }); + }); }); }); + +describe('The /api/{url} redirect', () => { + let server; + let counter = 0; // count number of current test method + before(done => { + process.env.port = '2999'; + db.connect('test', done); + }); + beforeEach(done => { + process.env.NODE_ENV = counter === 1 ? 'production' : 'test'; + counter ++; + server = TestHelper.beforeEach(server, done); + }); + afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); + + + it('returns the right method', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/api/authorized', + auth: {basic: 'admin'}, + httpStatus: 200, + res: {status: 'Authorization successful', method: 'basic'} + }); + }); + it('is disabled in production', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/api/authorized', + auth: {basic: 'admin'}, + httpStatus: 404 + }); + }); +}); \ No newline at end of file diff --git a/src/routes/root.ts b/src/routes/root.ts index 896f360..1547844 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -1,9 +1,35 @@ import express from 'express'; +import globals from '../globals'; +import RootValidate from './validate/root'; +import res400 from './validate/res400'; +import ChangelogModel from '../models/changelog'; +import mongoose from 'mongoose'; +import _ from 'lodash'; const router = express.Router(); router.get('/', (req, res) => { - res.json({message: 'API server up and running!'}); + res.json({status: 'API server up and running!'}); +}); + +router.get('/authorized', (req, res) => { + if (!req.auth(res, globals.levels)) return; + res.json({status: 'Authorization successful', method: req.authDetails.method}); +}); + +// TODO: evaluate exact changelog functionality (restoring, delting after time, etc.) +router.get('/changelog/:timestamp/:page?/:pagesize?', (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + const {error, value: options} = RootValidate.changelogParams({timestamp: req.params.timestamp, page: req.params.page, pagesize: req.params.pagesize}); + if (error) return res400(error, res); + + const id = new mongoose.Types.ObjectId(Math.floor(new Date(options.timestamp).getTime() / 1000).toString(16) + '0000000000000000'); + ChangelogModel.find({_id: {$lte: id}}).sort({_id: -1}).skip(options.page * options.pagesize).limit(options.pagesize).lean().exec((err, data) => { + if (err) return next(err); + + res.json(_.compact(data.map(e => RootValidate.changelogOutput(e)))); // validate all and filter null values from validation errors + }); }); module.exports = router; diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts new file mode 100644 index 0000000..7dc5f24 --- /dev/null +++ b/src/routes/sample.spec.ts @@ -0,0 +1,1930 @@ +import should from 'should/as-function'; +import SampleModel from '../models/sample'; +import NoteModel from '../models/note'; +import NoteFieldModel from '../models/note_field'; +import MeasurementModel from '../models/measurement'; +import TestHelper from "../test/helper"; +import globals from '../globals'; +import mongoose from 'mongoose'; + +// TODO: generate output for ML in format DPT -> data, implement filtering, field selection +// TODO: generate csv +// TODO: write script for data import +// TODO: allowed types: tension rod, part, granulate, other + +describe('/sample', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); + + // TODO: sort, added date filter, has measurements/condition filter + // TODO: check if conditions work in sort/fields/filters + // TODO: test for numbers as strings in glass_fiber + describe('GET /samples', () => { + it('returns all samples', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.validated).length); + should(res.body).matchEach(sample => { + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); + should(sample).have.property('_id').be.type('string'); + should(sample).have.property('number').be.type('string'); + should(sample).have.property('type').be.type('string'); + should(sample).have.property('color').be.type('string'); + should(sample).have.property('batch').be.type('string'); + should(sample).have.property('condition').be.type('object'); + should(sample.condition).have.property('condition_template').be.type('string'); + should(sample).have.property('material_id').be.type('string'); + should(sample).have.property('note_id'); + should(sample).have.property('user_id').be.type('string'); + should(sample).have.property('added').be.type('string'); + }); + done(); + }); + }); + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples', + auth: {key: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.validated).length); + should(res.body).matchEach(sample => { + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); + should(sample).have.property('_id').be.type('string'); + should(sample).have.property('number').be.type('string'); + should(sample).have.property('type').be.type('string'); + should(sample).have.property('color').be.type('string'); + should(sample).have.property('batch').be.type('string'); + should(sample).have.property('condition').be.type('object'); + should(sample.condition).have.property('condition_template').be.type('string'); + should(sample).have.property('material_id').be.type('string'); + should(sample).have.property('note_id'); + should(sample).have.property('user_id').be.type('string'); + should(sample).have.property('added').be.type('string'); + }); + done(); + }); + }); + it('allows filtering by state', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=new', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.new).length); + should(res.body).matchEach(sample => { + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); + should(sample).have.property('_id').be.type('string'); + should(sample).have.property('number').be.type('string'); + should(sample).have.property('type').be.type('string'); + should(sample).have.property('color').be.type('string'); + should(sample).have.property('batch').be.type('string'); + should(sample).have.property('condition').be.type('object'); + should(sample).have.property('material_id').be.type('string'); + should(sample).have.property('note_id'); + should(sample).have.property('user_id').be.type('string'); + should(sample).have.property('added').be.type('string'); + }); + done(); + }); + }); + it('uses the given page size', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&page-size=3', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(3); + done(); + }); + }); + it('returns results starting from first-id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&from-id=400000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('_id', '400000000000000000000002'); + should(res.body[1]).have.property('_id', '400000000000000000000003'); + done(); + }); + }); + it('returns the right page number', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&to-page=2&page-size=2', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('_id', '400000000000000000000006'); + done(); + }); + }); + it('works with negative page numbers', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&to-page=-1&page-size=2&from-id=400000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('_id', '400000000000000000000002'); + should(res.body[1]).have.property('_id', '400000000000000000000003'); + done(); + }); + }); + it('returns an empty array for a page number out of range', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&to-page=100&page-size=2', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(0); + should(res.body).be.eql([]); + done(); + }); + }); + it('returns an empty array for a page number out of negative range', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&to-page=-100&page-size=3&from-id=400000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(0); + should(res.body).be.eql([]); + done(); + }); + }); + it('sorts the samples ascending', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&sort=color-asc', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('color', 'black'); + should(res.body[res.body.length - 1]).have.property('color', 'natural'); + done(); + }); + }); + it('sorts the samples descending', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&sort=number-desc', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('number', 'Rng36'); + should(res.body[1]).have.property('number', '33'); + should(res.body[res.body.length - 1]).have.property('number', '1'); + done(); + }); + }); + it('sorts the samples correctly in combination with paging', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&sort=color-asc&page-size=2&from-id=400000000000000000000006', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('_id', '400000000000000000000006'); + should(res.body[1]).have.property('_id', '400000000000000000000002'); + done(); + }); + }); + it('sorts the samples correctly in combination with going pages backward', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&sort=color-desc&page-size=2&from-id=400000000000000000000004&to-page=-1', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('_id', '400000000000000000000002'); + should(res.body[1]).have.property('_id', '400000000000000000000006'); + done(); + }); + }); + it('sorts the samples correctly for material keys', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&sort=material.name-desc', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('_id', '400000000000000000000002'); + should(res.body[1]).have.property('_id', '400000000000000000000006'); + should(res.body[2]).have.property('_id', '400000000000000000000001'); + done(); + }); + }); + it('adds the specified measurements', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=measurements.kf', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body.find(e => e.number === '1')).have.property('kf', {'weight %': null, 'standard deviation': null}); + should(res.body.find(e => e.number === 'Rng36')).have.property('kf', {'weight %': 0.6, 'standard deviation': null}); + done(); + }); + }); + it('multiplies the sample information for each spectrum', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=measurements.spectrum', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(2); + should(res.body[0]).have.property('spectrum', [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]); + should(res.body[1]).have.property('spectrum', [[3996.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]); + done(); + }); + }); + it('filters a sample property', done => { // TODO: implement filters + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=type&filters[]=%7B%22mode%22%3A%22eq%22%2C%22field%22%3A%22type%22%2C%22values%22%3A%5B%22part%22%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.type === 'part').length); + should(res.body).matchEach(sample => { + should(sample).have.property('type', 'part'); + }); + done(); + }); + }); + it('filters a material property', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=material.name&filters[]=%7B%22mode%22%3A%22in%22%2C%22field%22%3A%22material.name%22%2C%22values%22%3A%5B%22Schulamid%2066%20GF%2025%20H%22%2C%22Stanyl%20TW%20200%20F8%22%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.material_id == '100000000000000000000004' || e.material_id == '100000000000000000000001').length); + should(res.body).matchEach(sample => { + should(sample.material.name).be.equalOneOf('Schulamid 66 GF 25 H', 'Stanyl TW 200 F8'); + }); + done(); + }); + }); + it('filters by measurement value', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=material.name&fields[]=measurements.kf.weight%20%25&filters[]=%7B%22mode%22%3A%22gt%22%2C%22field%22%3A%22measurements.kf.weight%20%25%22%2C%22values%22%3A%5B0.5%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.measurements.filter(e => e.measurement_template == '300000000000000000000002' && e.values['weight %'] > 0.5).length); + should(res.body).matchEach(sample => { + should(sample.kf['weight %']).be.above(0.5); + }); + done(); + }); + }); + it('filters by measurement value not in the fields', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=material.name&filters[]=%7B%22mode%22%3A%22gt%22%2C%22field%22%3A%22measurements.kf.weight%20%25%22%2C%22values%22%3A%5B0.5%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.measurements.filter(e => e.measurement_template == '300000000000000000000002' && e.values['weight %'] > 0.5).length); + should(res.body[0]).have.property('number', 'Rng36'); + done(); + }); + }); + it('filters multiple properties', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=material.glass_fiber&fields[]=batch&filters[]=%7B%22mode%22%3A%22eq%22%2C%22field%22%3A%22material.glass_fiber%22%2C%22values%22%3A%5B33%5D%7D&filters[]=%7B%22mode%22%3A%22lte%22%2C%22field%22%3A%22number%22%2C%22values%22%3A%5B%22Rng33%22%5D%7D&filters[]=%7B%22mode%22%3A%22nin%22%2C%22field%22%3A%22batch%22%2C%22values%22%3A%5B%221704-005%22%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(1); + should(res.body[0]).be.eql({number: '32', material: {glass_fiber: 33}, batch: '1653000308'}); + done(); + }); + }); // TODO: do measurement pipeline, check if it works with UI + it('rejects an invalid JSON string as a filters parameter', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=material.glass_fiber&fields[]=batch&filters[]=xx', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: 'Invalid JSON string for filter parameter'} + }); + }); + it('rejects an invalid filter mode', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=material.glass_fiber&fields[]=batch&filters[]=%7B%22mode%22%3A%22xx%22%2C%22field%22%3A%22batch%22%2C%22values%22%3A%5B%221704-005%22%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"filters[0].mode" must be one of [eq, ne, lt, lte, gt, gte, in, nin]'} + }); + }); + it('rejects an filter field not existing', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=material.glass_fiber&fields[]=batch&filters[]=%7B%22mode%22%3A%22eq%22%2C%22field%22%3A%22xx%22%2C%22values%22%3A%5B%221704-005%22%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"filters[0].field" with value "xx" fails to match the required pattern: /^(_id|color|number|type|batch|added|material\\.name|material\\.supplier|material\\.group|material\\.mineral|material\\.glass_fiber|material\\.carbon_fiber|material\\.number|measurements\\.(?!spectrum).+|condition|material_id|material|note_id|user_id|material\\._id|material\\.numbers|measurements\\.spectrum)$/m'} + }); + }); + it('rejects unknown measurement names', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=measurements.xx', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: 'Measurement key not found'} + }); + }); + it('returns a correct csv file if specified', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&page-size=2&csv=true', + contentType: /text\/csv/, + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.text).be.eql('"_id","number","type","color","batch","condition.material","condition.weeks","condition.condition_template","material_id","note_id","user_id","added"\r\n' + + '"400000000000000000000001","1","granulate","black","","copper",3,"200000000000000000000001","100000000000000000000004",,"000000000000000000000002","2004-01-10T13:37:04.000Z"\r\n' + + '"400000000000000000000002","21","granulate","natural","1560237365","copper",3,"200000000000000000000001","100000000000000000000001","500000000000000000000001","000000000000000000000002","2004-01-10T13:37:04.000Z"'); + done(); + }); + }); + it('returns only the fields specified', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&page-size=1&fields[]=number&fields[]=condition&fields[]=color&fields[]=material.name&fields[]=material.mineral', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: [{number: '1', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, color: 'black', material: {name: 'Schulamid 66 GF 25 H', mineral: 0}}] + }); + }); + it('rejects a from-id not in the database', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?from-id=5ea0450ed851c30a90e70894&sort=color-asc', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: 'from-id not found'} + }); + }); + it('rejects an invalid fields parameter', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&page-size=1&fields=number', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"fields" must be an array'} + }); + }); + it('rejects an unknown field name', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&page-size=1&fields[]=xx', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"fields[0]" with value "xx" fails to match the required pattern: /^(_id|color|number|type|batch|added|material\\.name|material\\.supplier|material\\.group|material\\.mineral|material\\.glass_fiber|material\\.carbon_fiber|material\\.number|measurements\\.(?!spectrum).+|condition|material_id|material|note_id|user_id|material\\._id|material\\.numbers|measurements\\.spectrum)$/m'} + }); + }); + it('rejects a negative page size', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?page-size=-3', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"page-size" must be larger than or equal to 1'} + }); + }); + it('rejects an invalid from-id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?from-id=40000000000h000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"from-id" with value "40000000000h000000000002" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects a to-page without page-size', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?to-page=3', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"to-page" missing required peer "page-size"'} + }); + }); + it('rejects an invalid state name', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=xxx', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"status" must be one of [validated, new, all]'} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples', + httpStatus: 401 + }); + }); + }); + + describe('GET /samples/{state}', () => { + it('returns all new samples', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/new', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + let asyncCounter = res.body.length; + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.new).length); + should(res.body).matchEach(sample => { + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); + should(sample).have.property('_id').be.type('string'); + should(sample).have.property('number').be.type('string'); + should(sample).have.property('type').be.type('string'); + should(sample).have.property('color').be.type('string'); + should(sample).have.property('batch').be.type('string'); + should(sample).have.property('condition').be.type('object'); + if (Object.keys(sample.condition).length > 0) { + should(sample.condition).have.property('condition_template').be.type('string'); + } + should(sample).have.property('material_id').be.type('string'); + should(sample).have.property('note_id'); + should(sample).have.property('user_id').be.type('string'); + should(sample).have.property('added').be.type('string'); + SampleModel.findById(sample._id).lean().exec((err, data) => { + should(data).have.property('status',globals.status.new); + if (--asyncCounter === 0) { + done(); + } + }); + }); + }); + }); + it('returns all deleted samples', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/deleted', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + let asyncCounter = res.body.length; + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === -1).length); + should(res.body).matchEach(sample => { + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); + should(sample).have.property('_id').be.type('string'); + should(sample).have.property('number').be.type('string'); + should(sample).have.property('type').be.type('string'); + should(sample).have.property('color').be.type('string'); + should(sample).have.property('batch').be.type('string'); + should(sample).have.property('condition').be.type('object'); + should(sample.condition).have.property('condition_template').be.type('string'); + should(sample.condition).have.property('condition_template').be.type('string'); + should(sample.condition).have.property('condition_template').be.type('string'); + should(sample).have.property('material_id').be.type('string'); + should(sample).have.property('note_id'); + should(sample).have.property('user_id').be.type('string'); + should(sample).have.property('added').be.type('string'); + SampleModel.findById(sample._id).lean().exec((err, data) => { + should(data).have.property('status',globals.status.deleted); + if (--asyncCounter === 0) { + done(); + } + }); + }); + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/new', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/new', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/new', + httpStatus: 401 + }); + }); + }); + + describe('GET /samples/count', () => { + it('returns the correct number of samples', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/count', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body.count).be.eql(json.collections.samples.length); + done(); + }); + }); + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/count', + auth: {key: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body.count).be.eql(json.collections.samples.length); + done(); + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/count', + httpStatus: 401 + }); + }); + }); + + describe('GET /sample/{id}', () => { + it('returns the right sample', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/400000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '400000000000000000000003', number: '33', type: 'part', color: 'black', batch: '1704-005', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {comment: '', sample_references: [{sample_id: '400000000000000000000004', relation: 'granulate to sample'}], custom_fields: {'not allowed for new applications': true}}, measurements: [{_id: '800000000000000000000003', sample_id: '400000000000000000000003', values: {val1: 1}, measurement_template: '300000000000000000000003'}], user: 'admin'} + }); + }); + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/400000000000000000000003', + auth: {key: 'janedoe'}, + httpStatus: 200, + res: {_id: '400000000000000000000003', number: '33', type: 'part', color: 'black', batch: '1704-005', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {comment: '', sample_references: [{sample_id: '400000000000000000000004', relation: 'granulate to sample'}], custom_fields: {'not allowed for new applications': true}}, measurements: [{_id: '800000000000000000000003', sample_id: '400000000000000000000003', values: {val1: 1}, measurement_template: '300000000000000000000003'}], user: 'admin'} + }); + }); + it('returns a deleted sample for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/400000000000000000000005', + auth: {basic: 'admin'}, + httpStatus: 200, + res: {_id: '400000000000000000000005', number: 'Rng33', type: 'granulate', color: 'black', batch: '1653000308', condition: {condition_template: '200000000000000000000003'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {}, measurements: [], user: 'admin'} + }); + }); + it('returns 403 for a write user when requesting a deleted sample', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/400000000000000000000005', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/000000000000000000000005', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/400000000h00000000000005', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/400000000000000000000005', + httpStatus: 401 + }); + }); + }); + + describe('PUT /sample/{id}', () => { // TODO: fix tests, work on /samples + it('returns the right sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {}, + res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'} + }); + }); + it('keeps unchanged properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', notes: {}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'}); + SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v'); + should(data).have.property('_id'); + should(data).have.property('number', '1'); + should(data).have.property('color', 'black'); + should(data).have.property('type', 'granulate'); + should(data).have.property('batch', ''); + should(data).have.property('condition', {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}); + should(data.material_id.toString()).be.eql('100000000000000000000004'); + should(data.user_id.toString()).be.eql('000000000000000000000002'); + should(data).have.property('status',globals.status.validated); + should(data).have.property('note_id', null); + done(); + }); + }); + }); + it('keeps only one unchanged parameter', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {type: 'granulate'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'}); + SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.property('status',globals.status.validated); + done(); + }); + }); + }); + it('keeps an unchanged condition', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'}); + SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.property('status',globals.status.validated); + done(); + }); + }); + }); + it('keeps unchanged notes', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {notes: {comment: 'Stoff gesperrt', sample_references: []}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '400000000000000000000002', number: '21', type: 'granulate', color: 'natural', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', note_id: '500000000000000000000001', user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'}); + SampleModel.findById('400000000000000000000002').lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v'); + should(data).have.property('_id'); + should(data).have.property('number', '21'); + should(data).have.property('color', 'natural'); + should(data).have.property('type', 'granulate'); + should(data).have.property('batch', '1560237365'); + should(data.condition).have.property('material', 'copper'); + should(data.condition).have.property('weeks', 3); + should(data.condition.condition_template.toString()).be.eql('200000000000000000000001'); + should(data.material_id.toString()).be.eql('100000000000000000000001'); + should(data.user_id.toString()).be.eql('000000000000000000000002'); + should(data).have.property('status',globals.status.validated); + should(data.note_id.toString()).be.eql('500000000000000000000001'); + done(); + }); + }); + }); + it('changes the given properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {type: 'part', color: 'signalviolet', batch: '114531', condition: {condition_template: '200000000000000000000003'}, material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} + }).end(err => { + if (err) return done (err); + SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v'); + should(data).have.property('_id'); + should(data).have.property('number', '1'); + should(data).have.property('color', 'signalviolet'); + should(data).have.property('type', 'part'); + should(data).have.property('batch', '114531'); + should(data).have.property('condition', {condition_template: '200000000000000000000003'}); + should(data.material_id.toString()).be.eql('100000000000000000000002'); + should(data.user_id.toString()).be.eql('000000000000000000000002'); + should(data).have.property('status',globals.status.new); + should(data).have.property('note_id'); + NoteModel.findById(data.note_id).lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.property('_id'); + should(data).have.property('comment', 'Testcomment'); + should(data).have.property('sample_references'); + should(data.sample_references).have.lengthOf(1); + should(data.sample_references[0].sample_id.toString()).be.eql('400000000000000000000003'); + should(data.sample_references[0]).have.property('relation', 'part to this sample'); + done(); + }); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {type: 'part', color: 'signalviolet', batch: '114531', condition: {condition_template: '200000000000000000000003'}, material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + log: { + collection: 'samples', + dataAdd: { + status: 0 + }, + dataIgn: ['notes', 'note_id'] + } + }); + }); + it('adjusts the note_fields correctly', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {notes: {comment: 'Testcomment', sample_references: [], custom_fields: {field1: 'value 1'}}} + }).end(err => { + if (err) return done(err); + NoteFieldModel.findOne({name: 'not allowed for new applications'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('qty', 1); + NoteFieldModel.findOne({name: 'field1'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('qty', 1); + done(); + }); + }); + }); + }); + it('deletes old note_fields', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {notes: {comment: 'Testcomment', sample_references: []}} + }).end(err => { + if (err) return done (err); + NoteFieldModel.findOne({name: 'another_field'}).lean().exec((err, data) => { + if (err) return done (err); + should(data).be.null(); + done(); + }); + }); + }); + it('keeps untouched notes', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {type: 'part'} + }).end((err, res) => { + if (err) return done (err); + NoteModel.findById(res.body.note_id).lean().exec((err, data) => { + if (err) return done (err); + should(data).not.be.null(); + should(data).have.property('comment', 'Stoff gesperrt'); + should(data).have.property('sample_references').have.lengthOf(0); + done(); + }); + }); + }); + it('deletes old notes', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {notes: {comment: 'Testcomment', sample_references: []}} + }).end(err => { + if (err) return done (err); + NoteModel.findById('500000000000000000000003').lean().exec((err, data) => { + if (err) return done (err); + should(data).be.null(); + done(); + }); + }); + }); + it('rejects a color not defined for the material', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Color not available for material'} + }); + }); + it('rejects an undefined color for the same material', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {type: 'part', color: 'signalviolet', batch: '114531', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Color not available for material'} + }); + }); + it('rejects an unknown material id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '000000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Material not available'} + }); + }); + it('rejects a sample number', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: 25, type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format', details: '"number" is not allowed'} + }); + }); + it('rejects an invalid sample reference', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '000000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Sample reference not available'} + }); + }); + it('rejects an invalid material id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format', details: '"material_id" with value "10000000000h000000000001" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/10000000000h000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }); + }); + it('rejects not specified condition parameters', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {material: 'copper', weeks: 3, xxx: 44, condition_template: '200000000000000000000001'}}, + res: {status: 'Invalid body format', details: '"xxx" is not allowed'} + }); + }); + it('rejects a condition parameter not in the value range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {material: 'xx', weeks: 3, condition_template: '200000000000000000000001'}}, + res: {status: 'Invalid body format', details: '"material" must be one of [copper, hot air]'} + }); + }); + it('rejects a condition parameter below minimum range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {material: 'copper', weeks: 0, condition_template: '200000000000000000000001'}}, + res: {status: 'Invalid body format', details: '"weeks" must be larger than or equal to 1'} + }); + }); + it('rejects a condition parameter above maximum range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {material: 'copper', weeks: 10.5, condition_template: '200000000000000000000001'}}, + res: {status: 'Invalid body format', details: '"weeks" must be less than or equal to 10'} + }); + }); + it('rejects an invalid condition template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {material: 'copper', weeks: 3, condition_template: '200000000000h00000000001'}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects an unknown condition template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {material: 'copper', weeks: 3, condition_template: '000000000000000000000001'}}, + res: {status: 'Condition template not available'} + }); + }); + it('allows keeping an empty condition empty', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000006', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {condition: {}}, + res: {_id: '400000000000000000000006', number: 'Rng36', type: 'granulate', color: 'black', batch: '', condition: {}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'} + }); + }); + it('rejects an old version of a condition template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {p1: 36, condition_template: '200000000000000000000004'}}, + res: {status: 'Old template version not allowed'} + }); + }); + it('allows keeping an old version of a condition template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {condition: {p1: 36, condition_template: '200000000000000000000004'}}, + res: {_id: '400000000000000000000004', number: '32', type: 'granulate', color: 'black', batch: '1653000308', condition: {p1: 36, condition_template: '200000000000000000000004'}, material_id: '100000000000000000000005', note_id: '500000000000000000000003', user_id: '000000000000000000000003', added: '2004-01-10T13:37:04.000Z'} + }); + }); + it('rejects an changing back to an empty condition', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects editing a deleted sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000005', + auth: {basic: 'admin'}, + httpStatus: 403, + req: {} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }); + }); + it('rejects changes for samples from another user for a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('accepts changes for samples from another user for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {condition_template: '200000000000000000000001', material: 'copper', weeks: 3}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'} + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'user'}, + httpStatus: 403, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/000000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} + }); + }) + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + httpStatus: 401, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }); + }); + }); + + describe('DELETE /sample/{id}', () => { + it('sets the status to deleted', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v'); + should(data).have.property('_id'); + should(data).have.property('number', '1'); + should(data).have.property('color', 'black'); + should(data).have.property('type', 'granulate'); + should(data).have.property('batch', ''); + should(data.condition).have.property('material', 'copper'); + should(data.condition).have.property('weeks', 3); + should(data.condition.condition_template.toString()).be.eql('200000000000000000000001'); + should(data.material_id.toString()).be.eql('100000000000000000000004'); + should(data.user_id.toString()).be.eql('000000000000000000000002'); + should(data).have.property('status',globals.status.deleted); + should(data).have.property('note_id', null); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + log: { + collection: 'samples', + skip: 1, + dataAdd: {status: -1} + } + }); + }); + it('keeps the notes of the sample', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + NoteModel.findById('500000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).have.only.keys('_id', 'comment', 'sample_references', '__v'); + should(data).have.property('comment', 'Stoff gesperrt'); + should(data).have.property('sample_references').with.lengthOf(0); + done(); + }); + }); + }); + it('adjusts the note_fields correctly', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + NoteFieldModel.findOne({name: 'not allowed for new applications'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('qty', 1); + NoteFieldModel.findOne({name: 'another_field'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).be.null(); + done(); + }); + }); + }); + }); + it('keeps references to this sample', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + setTimeout(() => { // background action takes some time before we can check + NoteModel.findById('500000000000000000000003').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('sample_references').with.lengthOf(1); + should(data.sample_references[0].sample_id.toString()).be.eql('400000000000000000000003'); + should(data.sample_references[0]).have.property('relation', 'part to sample'); + done(); + }); + }, 100); + + }); + }); + it('lets admin/maintain users delete samples of other users', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + SampleModel.findById('400000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('status',globals.status.deleted); + done(); + }); + }); + }); + it('deletes associated measurements', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + MeasurementModel.find({sample_id: mongoose.Types.ObjectId('400000000000000000000001')}).lean().exec((err, data: any) => { + if (err) return done(err); + should(data).matchEach(sample => { + should(sample).have.property('status', -1); + }); + done(); + }); + }); + }); + it('rejects deleting samples of other users for write users', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000h00000000004', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000004', + auth: {basic: 'user'}, + httpStatus: 403 + }); + }); + it('returns 404 for an unknown id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/000000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + httpStatus: 401 + }); + }); + }); + + describe('PUT /sample/restore/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + SampleModel.findById('400000000000000000000005').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.new); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'samples', + dataAdd: { + group_id: '900000000000000000000002', + supplier_id: '110000000000000000000002', + status: 0 + }, + dataIgn: ['group_id', 'supplier_id'] + } + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/000000000000000000000005', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('PUT /sample/validate/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + SampleModel.findById('400000000000000000000003').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.validated); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'samples', + dataAdd: { + group_id: '900000000000000000000002', + supplier_id: '110000000000000000000002', + status: 10 + }, + dataIgn: ['group_id', 'supplier_id'] + } + }); + }); + it('rejects validating a sample without condition', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000006', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {}, + res: {status: 'Sample without condition cannot be valid'} + }); + }); + it('rejects validating a sample without measurements', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {}, + res: {status: 'Sample without measurements cannot be valid'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000003', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/000000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000003', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('POST /sample/new', () => { + it('returns the right sample', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('number', 'Rng37'); + should(res.body).have.property('color', 'black'); + should(res.body).have.property('type', 'granulate'); + should(res.body).have.property('batch', '1560237365'); + should(res.body).have.property('condition', {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}); + should(res.body).have.property('material_id', '100000000000000000000001'); + should(res.body).have.property('note_id').be.type('string'); + should(res.body).have.property('user_id', '000000000000000000000002'); + should(res.body).have.property('added').be.type('string'); + should(new Date().getTime() - new Date(res.body.added).getTime()).be.below(1000); + done(); + }); + }); + it('stores the sample', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} + }).end(err => { + if (err) return done (err); + SampleModel.find({number: 'Rng37'}).lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v'); + should(data[0]).have.property('_id'); + should(data[0]).have.property('number', 'Rng37'); + should(data[0]).have.property('color', 'black'); + should(data[0]).have.property('type', 'granulate'); + should(data[0]).have.property('batch', '1560237365'); + should(data[0]).have.property('condition', {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}); + should(data[0].material_id.toString()).be.eql('100000000000000000000001'); + should(data[0].user_id.toString()).be.eql('000000000000000000000002'); + should(data[0]).have.property('status',globals.status.new); + should(data[0]).have.property('note_id'); + NoteModel.findById(data[0].note_id).lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.property('_id'); + should(data).have.property('comment', 'Testcomment'); + should(data).have.property('sample_references'); + should(data.sample_references).have.lengthOf(1); + should(data.sample_references[0].sample_id.toString()).be.eql('400000000000000000000003'); + should(data.sample_references[0]).have.property('relation', 'part to this sample'); + done(); + }); + }) + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + log: { + collection: 'samples', + dataAdd: { + number: 'Rng37', + user_id: '000000000000000000000002', + status: 0 + }, + dataIgn: ['notes', 'note_id'] + } + }); + }); + it('stores the custom fields', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [], custom_fields: {field1: 'a', field2: 'b', 'not allowed for new applications': true}}} + }).end((err, res) => { + if (err) return done (err); + NoteModel.findById(res.body.note_id).lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('_id'); + should(data).have.property('comment', 'Testcomment'); + should(data).have.property('sample_references').have.lengthOf(0); + should(data).have.property('custom_fields'); + should(data.custom_fields).have.property('field1', 'a'); + should(data.custom_fields).have.property('field2', 'b'); + should(data.custom_fields).have.property('not allowed for new applications', true); + NoteFieldModel.find({name: 'field1'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.property('qty', 1); + NoteFieldModel.find({name: 'field2'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.property('qty', 1); + NoteFieldModel.find({name: 'not allowed for new applications'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.property('qty', 3); + done(); + }); + }); + }); + }); + }); + }); + it('stores a new sample location as 1', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'johnnydoe'}, + httpStatus: 200, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('number', 'Fe1'); + should(res.body).have.property('color', 'black'); + should(res.body).have.property('type', 'granulate'); + should(res.body).have.property('batch', '1560237365'); + should(res.body).have.property('material_id', '100000000000000000000001'); + should(res.body).have.property('note_id').be.type('string'); + should(res.body).have.property('user_id', '000000000000000000000004'); + should(res.body).have.property('added').be.type('string'); + should(new Date().getTime() - new Date(res.body.added).getTime()).be.below(1500); + done(); + }); + }); + it('accepts a sample without condition', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('number', 'Rng37'); + should(res.body).have.property('color', 'black'); + should(res.body).have.property('type', 'granulate'); + should(res.body).have.property('batch', '1560237365'); + should(res.body).have.property('condition', {}); + should(res.body).have.property('material_id', '100000000000000000000001'); + should(res.body).have.property('note_id').be.type('string'); + should(res.body).have.property('user_id', '000000000000000000000002'); + should(res.body).have.property('added').be.type('string'); + should(new Date().getTime() - new Date(res.body.added).getTime()).be.below(1000); + done(); + }); + }); + it('rejects a color not defined for the material', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'green', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Color not available for material'} + }); + }); + it('rejects an unknown material id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '000000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Material not available'} + }); + }); + it('rejects a sample number for a write user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: 'Rng34', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format', details: '"number" is not allowed'} + }); + }); + it('allows a sample number for an admin user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {number: 'Rng34', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('number', 'Rng34'); + should(res.body).have.property('color', 'black'); + should(res.body).have.property('type', 'granulate'); + should(res.body).have.property('batch', '1560237365'); + should(res.body).have.property('condition', {}); + should(res.body).have.property('material_id', '100000000000000000000001'); + should(res.body).have.property('note_id').be.type('string'); + should(res.body).have.property('user_id', '000000000000000000000003'); + should(res.body).have.property('added').be.type('string'); + should(new Date().getTime() - new Date(res.body.added).getTime()).be.below(1000); + done(); + }); + }); + it('rejects an existing sample number for an admin user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {number: 'Rng33', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Sample number already taken'} + }); + }); + it('rejects an invalid sample reference', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '000000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Sample reference not available'} + }); + }); + it('rejects an invalid condition_template id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '20000h000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects a not existing condition_template id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '000000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects not specified condition parameters', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, xxx: 23, condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects missing condition parameters', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects condition parameters not in the value range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'xxx', weeks: 3, condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects a condition parameter below minimum range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 0, condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects a condition parameter above maximum range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 11, condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects a condition without condition template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects an old version of a condition template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {p1: 36, condition_template: '200000000000000000000004'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Old template version not allowed'} + }); + }); + it('rejects a missing color', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format', details: '"color" is required'} + }); + }); + it('rejects a missing type', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format', details: '"type" is required'} + }); + }); + it('rejects a missing batch', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format', details: '"batch" is required'} + }); + }); + it('rejects a missing material id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format', details: '"material_id" is required'} + }); + }); + it('rejects an invalid material id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format', details: '"material_id" with value "10000000000h000000000001" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'user'}, + httpStatus: 403, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + httpStatus: 401, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} + }); + }); + }); + + describe('GET /sample/notes/fields', () => { + it('returns all fields', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/notes/fields', + auth: {basic: 'user'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.note_fields.length); + should(res.body).matchEach(material => { + should(material).have.only.keys('name', 'qty'); + should(material).have.property('qty').be.type('number'); + }); + done(); + }); + }); + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/notes/fields', + auth: {key: 'user'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.note_fields.length); + should(res.body).matchEach(material => { + should(material).have.only.keys('name', 'qty'); + should(material).have.property('qty').be.type('number'); + }); + done(); + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/notes/fields', + httpStatus: 401 + }); + }); + }); +}); diff --git a/src/routes/sample.ts b/src/routes/sample.ts new file mode 100644 index 0000000..91ada86 --- /dev/null +++ b/src/routes/sample.ts @@ -0,0 +1,780 @@ +import express from 'express'; +import _ from 'lodash'; + +import SampleValidate from './validate/sample'; +import NoteFieldValidate from './validate/note_field'; +import res400 from './validate/res400'; +import SampleModel from '../models/sample' +import MeasurementModel from '../models/measurement'; +import MeasurementTemplateModel from '../models/measurement_template'; +import MaterialModel from '../models/material'; +import NoteModel from '../models/note'; +import NoteFieldModel from '../models/note_field'; +import IdValidate from './validate/id'; +import mongoose from 'mongoose'; +import ConditionTemplateModel from '../models/condition_template'; +import ParametersValidate from './validate/parameters'; +import globals from '../globals'; +import db from '../db'; +import csv from '../helpers/csv'; + + +const router = express.Router(); + +// TODO: check added filter +// TODO: return total number of pages -> use facet +// TODO: use query pointer +// TODO: convert filter value to number according to table model +// TODO: validation for filter parameters +// TODO: location/device sort/filter +router.get('/samples', async (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + const {error, value: filters} = SampleValidate.query(req.query); + if (error) return res400(error, res); + + // TODO: find a better place for these + const sampleKeys = ['_id', 'color', 'number', 'type', 'batch', 'added', 'condition', 'material_id', 'note_id', 'user_id']; + + // evaluate sort parameter from 'color-asc' to ['color', 1] + filters.sort = filters.sort.split('-'); + filters.sort[0] = filters.sort[0] === 'added' ? '_id' : filters.sort[0]; // route added sorting criteria to _id + filters.sort[1] = filters.sort[1] === 'desc' ? -1 : 1; + if (!filters['to-page']) { // set to-page default + filters['to-page'] = 0; + } + const addedFilter = filters.filters.find(e => e.field === 'added'); + if (addedFilter) { // convert added filter to object id + filters.filters.splice(filters.filters.findIndex(e => e.field === 'added'), 1); + if (addedFilter.mode === 'in') { + const v = []; // query value + addedFilter.values.forEach(value => { + const date = [new Date(value).setHours(0,0,0,0), new Date(value).setHours(23,59,59,999)]; + v.push({$and: [{ _id: { '$gte': dateToOId(date[0])}}, { _id: { '$lte': dateToOId(date[1])}}]}); + }); + filters.filters.push({mode: 'or', field: '_id', values: v}); + } + else if (addedFilter.mode === 'nin') { + addedFilter.values = addedFilter.values.sort(); + const v = []; // query value + + for (let i = 0; i <= addedFilter.values.length; i ++) { + v[i] = {$and: []}; + if (i > 0) { + const date = new Date(addedFilter.values[i - 1]).setHours(23,59,59,999); + v[i].$and.push({ _id: { '$gt': dateToOId(date)}}) ; + } + if (i < addedFilter.values.length) { + const date = new Date(addedFilter.values[i]).setHours(0,0,0,0); + v[i].$and.push({ _id: { '$lt': dateToOId(date)}}) ; + } + } + filters.filters.push({mode: 'or', field: '_id', values: v}); + } + else { + // start and end of day + const date = [new Date(addedFilter.values[0]).setHours(0,0,0,0), new Date(addedFilter.values[0]).setHours(23,59,59,999)]; + if (addedFilter.mode === 'lt') { // lt start + filters.filters.push({mode: 'lt', field: '_id', values: [dateToOId(date[0])]}); + } + if (addedFilter.mode === 'eq' || addedFilter.mode === 'lte') { // lte end + filters.filters.push({mode: 'lte', field: '_id', values: [dateToOId(date[1])]}); + } + if (addedFilter.mode === 'gt') { // gt end + filters.filters.push({mode: 'gt', field: '_id', values: [dateToOId(date[1])]}); + } + if (addedFilter.mode === 'eq' || addedFilter.mode === 'gte') { // gte start + filters.filters.push({mode: 'gte', field: '_id', values: [dateToOId(date[0])]}); + } + if (addedFilter.mode === 'ne') { + filters.filters.push({mode: 'or', field: '_id', values: [{ _id: { '$lt': dateToOId(date[0])}}, { _id: { '$gt': dateToOId(date[1])}}]}); + } + } + } + + const sortFilterKeys = filters.filters.map(e => e.field); + + let collection; + const query = []; + let queryPtr = query; + queryPtr.push({$match: {$and: []}}); + + if (filters.sort[0].indexOf('measurements.') >= 0) { // sorting with measurements as starting collection + collection = MeasurementModel; + const [,measurementName, measurementParam] = filters.sort[0].split('.'); + const measurementTemplate = await MeasurementTemplateModel.findOne({name: measurementName}).lean().exec().catch(err => {next(err);}); + if (measurementTemplate instanceof Error) return; + if (!measurementTemplate) { + return res.status(400).json({status: 'Invalid body format', details: filters.sort[0] + ' not found'}); + } + let sortStartValue = null; + if (filters['from-id']) { // from-id specified, fetch values for sorting + const fromSample = await MeasurementModel.findOne({sample_id: mongoose.Types.ObjectId(filters['from-id'])}).lean().exec().catch(err => {next(err);}); // TODO: what if more than one measurement for sample? + if (fromSample instanceof Error) return; + if (!fromSample) { + return res.status(400).json({status: 'Invalid body format', details: 'from-id not found'}); + } + sortStartValue = fromSample.values[measurementParam]; + } + queryPtr[0].$match.$and.push({measurement_template: mongoose.Types.ObjectId(measurementTemplate._id)}); // find measurements to sort + if (filters.filters.find(e => e.field === filters.sort[0])) { // sorted measurement should also be filtered + queryPtr[0].$match.$and.push(...filterQueries(filters.filters.filter(e => e.field === filters.sort[0]).map(e => {e.field = 'values.' + e.field.split('.')[2]; return e; }))); + } + queryPtr.push( + ...sortQuery(filters, ['values.' + measurementParam, 'sample_id'], sortStartValue), // sort measurements + {$replaceRoot: {newRoot: {measurement: '$$ROOT'}}}, // fetch samples and restructure them to fit sample structure + {$lookup: {from: 'samples', localField: 'measurement.sample_id', foreignField: '_id', as: 'sample'}}, + {$match: statusQuery(filters, 'sample.status')}, // filter out wrong status once samples were added + {$addFields: {['sample.' + measurementName]: '$measurement.values'}}, // more restructuring + {$replaceRoot: {newRoot: {$mergeObjects: [{$arrayElemAt: ['$sample', 0]}, {}]}}} + ); + } + else { // sorting with samples as starting collection + collection = SampleModel; + queryPtr[0].$match.$and.push(statusQuery(filters, 'status')); + + if (sampleKeys.indexOf(filters.sort[0]) >= 0) { // sorting for sample keys + let sortStartValue = null; + if (filters['from-id']) { // from-id specified + const fromSample = await SampleModel.findById(filters['from-id']).lean().exec().catch(err => { + next(err); + }); + if (fromSample instanceof Error) return; + if (!fromSample) { + return res.status(400).json({status: 'Invalid body format', details: 'from-id not found'}); + } + sortStartValue = fromSample[filters.sort[0]]; + } + queryPtr.push(...sortQuery(filters, [filters.sort[0], '_id'], sortStartValue)); + } + else { // add sort key to list to add field later + sortFilterKeys.push(filters.sort[0]); + } + } + + addFilterQueries(queryPtr, filters.filters.filter(e => sampleKeys.indexOf(e.field) >= 0)); // sample filters + + let materialQuery = []; // put material query together separate first to reuse for first-id + let materialAdded = false; + if (sortFilterKeys.find(e => /material\./.test(e))) { // add material fields + materialAdded = true; + materialQuery.push( // add material properties + {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, // TODO: project out unnecessary fields + {$addFields: {material: {$arrayElemAt: ['$material', 0]}}} + ); + const baseMFilters = sortFilterKeys.filter(e => /material\./.test(e)).filter(e => ['material.supplier', 'material.group', 'material.number'].indexOf(e) < 0); + addFilterQueries(materialQuery, filters.filters.filter(e => baseMFilters.indexOf(e.field) >= 0)); // base material filters + if (sortFilterKeys.find(e => e === 'material.supplier')) { // add supplier if needed + materialQuery.push( + {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, + {$addFields: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}} + ); + } + if (sortFilterKeys.find(e => e === 'material.group')) { // add group if needed + materialQuery.push( + {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, + {$addFields: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}} + ); + } + if (sortFilterKeys.find(e => e === 'material.number')) { // add material number if needed + materialQuery.push( + {$addFields: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]}}} + ); + } + const specialMFilters = sortFilterKeys.filter(e => /material\./.test(e)).filter(e => ['material.supplier', 'material.group', 'material.number'].indexOf(e) >= 0); + addFilterQueries(materialQuery, filters.filters.filter(e => specialMFilters.indexOf(e.field) >= 0)); // base material filters + queryPtr.push(...materialQuery); + if (/material\./.test(filters.sort[0])) { // sort by material key + let sortStartValue = null; + if (filters['from-id']) { // from-id specified + const fromSample = await SampleModel.aggregate([{$match: {_id: mongoose.Types.ObjectId(filters['from-id'])}}, ...materialQuery]).exec().catch(err => {next(err);}); + if (fromSample instanceof Error) return; + if (!fromSample) { + return res.status(400).json({status: 'Invalid body format', details: 'from-id not found'}); + } + sortStartValue = fromSample[filters.sort[0]]; + } + queryPtr.push(...sortQuery(filters, [filters.sort[0], '_id'], sortStartValue)); + } + } + + const measurementFilterFields = _.uniq(sortFilterKeys.filter(e => /measurements\./.test(e)).map(e => e.split('.')[1])); // filter measurement names and remove duplicates from parameters + if (sortFilterKeys.find(e => /measurements\./.test(e))) { // add measurement fields + const measurementTemplates = await MeasurementTemplateModel.find({name: {$in: measurementFilterFields}}).lean().exec().catch(err => {next(err);}); + if (measurementTemplates instanceof Error) return; + if (measurementTemplates.length < measurementFilterFields.length) { + return res.status(400).json({status: 'Invalid body format', details: 'Measurement key not found'}); + } + queryPtr.push({$lookup: { + from: 'measurements', let: {sId: '$_id'}, + pipeline: [{$match: {$expr: {$and: [{$eq: ['$sample_id', '$$sId']}, {$in: ['$measurement_template', measurementTemplates.map(e => mongoose.Types.ObjectId(e._id))]}]}}}], + as: 'measurements' + }}); + measurementTemplates.forEach(template => { + queryPtr.push({$addFields: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values + vars: {arr: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', mongoose.Types.ObjectId(template._id)]}}}}, + in:{$cond: [{$lte: [{$size: '$$arr'}, 1]}, {$arrayElemAt: ['$$arr', 0]}, '$$arr']} + }}}}, {$addFields: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', template.parameters.reduce((s, e) => {s[e.name] = null; return s;}, {})]}}}); + }); + addFilterQueries(queryPtr, filters.filters + .filter(e => sortFilterKeys.filter(e => /measurements\./.test(e)).indexOf(e.field) >= 0) + .map(e => {e.field = e.field.replace('measurements.', ''); return e; }) + ); // measurement filters + } + + if (!filters.fields.find(e => /spectrum\./.test(e)) && !filters['from-id']) { // count total number of items before $skip and $limit, only works when from-id is not specified and spectra are not included + queryPtr.push({$facet: {count: [{$count: 'count'}], samples: []}}); + queryPtr = queryPtr[queryPtr.length - 1].$facet.samples; // add rest of aggregation pipeline into $facet + } + + // paging + if (filters['to-page']) { + queryPtr.push({$skip: Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size'] + Number(filters['to-page'] < 0)}) // number to skip, if going back pages, one page has to be skipped less but on sample more + } + if (filters['page-size']) { + queryPtr.push({$limit: filters['page-size']}); + } + + const fieldsToAdd = filters.fields.filter(e => // fields to add + sortFilterKeys.indexOf(e) < 0 // field was not in filter + && e !== filters.sort[0] // field was not in sort + ); + + if (fieldsToAdd.find(e => /material\./.test(e)) && !materialAdded) { // add material, was not added already + queryPtr.push( + {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, + {$addFields: {material: { $arrayElemAt: ['$material', 0]}}} + ); + } + if (fieldsToAdd.indexOf('material.supplier') >= 0) { // add supplier if needed + queryPtr.push( + {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, + {$addFields: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}} + ); + } + if (fieldsToAdd.indexOf('material.group') >= 0) { // add group if needed + queryPtr.push( + {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, + {$addFields: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}} + ); + } + if (fieldsToAdd.indexOf('material.number') >= 0) { // add material number if needed + queryPtr.push( + {$addFields: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]}}} + ); + } + + let measurementFieldsFields: string[] = _.uniq(fieldsToAdd.filter(e => /measurements\./.test(e)).map(e => e.split('.')[1])); // filter measurement names and remove duplicates from parameters + if (fieldsToAdd.find(e => /measurements\./.test(e))) { // add measurement fields + const measurementTemplates = await MeasurementTemplateModel.find({name: {$in: measurementFieldsFields}}).lean().exec().catch(err => {next(err);}); + if (measurementTemplates instanceof Error) return; + if (measurementTemplates.length < measurementFieldsFields.length) { + return res.status(400).json({status: 'Invalid body format', details: 'Measurement key not found'}); + } + if (fieldsToAdd.find(e => /spectrum\./.test(e))) { // use different lookup methods with and without spectrum for the best performance + queryPtr.push({$lookup: {from: 'measurements', localField: '_id', foreignField: 'sample_id', as: 'measurements'}}); + } + else { + queryPtr.push({$lookup: { + from: 'measurements', let: {sId: '$_id'}, + pipeline: [{$match: {$expr: {$and: [{$eq: ['$sample_id', '$$sId']}, {$in: ['$measurement_template', measurementTemplates.map(e => mongoose.Types.ObjectId(e._id))]}]}}}], + as: 'measurements' + }}); + } + measurementTemplates.filter(e => e.name !== 'spectrum').forEach(template => { // TODO: hard coded dpt for special treatment, change later + queryPtr.push({$addFields: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values + vars: {arr: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', mongoose.Types.ObjectId(template._id)]}}}}, + in:{$cond: [{$lte: [{$size: '$$arr'}, 1]}, {$arrayElemAt: ['$$arr', 0]}, '$$arr']} + }}}}, {$addFields: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', template.parameters.reduce((s, e) => {s[e.name] = null; return s;}, {})]}}}); + }); + if (measurementFieldsFields.find(e => e === 'spectrum')) { // TODO: remove hardcoded as well + queryPtr.push( + {$addFields: {spectrum: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', measurementTemplates.filter(e => e.name === 'spectrum')[0]._id]}}}}}, + {$addFields: {spectrum: '$spectrum.values'}}, + {$unwind: '$spectrum'} + ); + } + // queryPtr.push({$unset: 'measurements'}); + queryPtr.push({$project: {measurements: 0}}); + } + + const projection = filters.fields.map(e => e.replace('measurements.', '')).reduce((s, e) => {s[e] = true; return s; }, {}); + if (filters.fields.indexOf('added') >= 0) { // add added date + // projection.added = {$toDate: '$_id'}; + // projection.added = { $convert: { input: '$_id', to: "date" } } // TODO: upgrade MongoDB version or find alternative + } + if (filters.fields.indexOf('_id') < 0 && filters.fields.indexOf('added') < 0) { // disable _id explicitly + projection._id = false; + } + queryPtr.push({$project: projection}); + + if (!fieldsToAdd.find(e => /spectrum\./.test(e))) { // use streaming when including spectrum files + collection.aggregate(query).exec((err, data) => { + if (err) return next(err); + if (data[0].count) { + res.header('x-total-items', data[0].count.length > 0 ? data[0].count[0].count : 0); + res.header('Access-Control-Expose-Headers', 'x-total-items'); + data = data[0].samples; + } + if (filters.fields.indexOf('added') >= 0) { // add added date + data.map(e => { + e.added = e._id.getTimestamp(); + if (filters.fields.indexOf('_id') < 0) { + delete e._id; + } + return e + }); + } + if (filters['to-page'] < 0) { + data.reverse(); + } + const measurementFields = _.uniq([filters.sort[0].split('.')[1], ...measurementFilterFields, ...measurementFieldsFields]); + if (filters.csv) { // output as csv + csv(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields))), (err, data) => { + if (err) return next(err); + res.set('Content-Type', 'text/csv'); + res.send(data); + }); + } + else { + res.json(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields)))); // validate all and filter null values from validation errors + } + }); + } + else { + res.writeHead(200, {'Content-Type': 'application/json; charset=utf-8'}); + res.write('['); + let count = 0; + const stream = collection.aggregate(query).cursor().exec(); + stream.on('data', data => { + if (filters.fields.indexOf('added') >= 0) { // add added date + data.added = data._id.getTimestamp(); + if (filters.fields.indexOf('_id') < 0) { + delete data._id; + } + } + res.write((count === 0 ? '' : ',\n') + JSON.stringify(data)); count ++; + }); + stream.on('close', () => { + res.write(']'); + res.end(); + }); + } +}); + +router.get('/samples/:state(new|deleted)', (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + SampleModel.find({status: globals.status[req.params.state]}).lean().exec((err, data) => { + if (err) return next(err); + res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors + }); +}); + +router.get('/samples/count', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + SampleModel.estimatedDocumentCount((err, data) => { + if (err) return next(err); + res.json({count: data}); + }); +}); + +router.get('/sample/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + SampleModel.findById(req.params.id).populate('material_id').populate('user_id', 'name').populate('note_id').exec(async (err, sampleData: any) => { + if (err) return next(err); + + if (sampleData) { + await sampleData.populate('material_id.group_id').populate('material_id.supplier_id').execPopulate().catch(err => next(err)); + if (sampleData instanceof Error) return; + sampleData = sampleData.toObject(); + + if (sampleData.status === globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted samples only available for maintain/admin + sampleData.material = sampleData.material_id; // map data to right keys + sampleData.material.group = sampleData.material.group_id.name; + sampleData.material.supplier = sampleData.material.supplier_id.name; + sampleData.user = sampleData.user_id.name; + sampleData.notes = sampleData.note_id ? sampleData.note_id : {}; + MeasurementModel.find({sample_id: mongoose.Types.ObjectId(req.params.id), status: {$ne: globals.status.deleted}}).lean().exec((err, data) => { + sampleData.measurements = data; + res.json(SampleValidate.output(sampleData, 'details')); + }); + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + +router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const {error, value: sample} = SampleValidate.input(req.body, 'change'); + if (error) return res400(error, res); + + SampleModel.findById(req.params.id).lean().exec(async (err, sampleData: any) => { // check if id exists + if (err) return next(err); + if (!sampleData) { + return res.status(404).json({status: 'Not found'}); + } + if (sampleData.status === globals.status.deleted) { + return res.status(403).json({status: 'Forbidden'}); + } + + // only maintain and admin are allowed to edit other user's data + if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; + + if (sample.hasOwnProperty('material_id')) { + if (!await materialCheck(sample, res, next)) return; + } + else if (sample.hasOwnProperty('color')) { + if (!await materialCheck(sample, res, next, sampleData.material_id)) return; + } + + if (sample.hasOwnProperty('condition') && !(_.isEmpty(sample.condition) && _.isEmpty(sampleData.condition))) { // do not execute check if condition is and was empty + if (!await conditionCheck(sample.condition, 'change', res, next, sampleData.condition.condition_template.toString() !== sample.condition.condition_template)) return; + } + + if (sample.hasOwnProperty('notes')) { + let newNotes = true; + if (sampleData.note_id !== null) { // old notes data exists + const data = await NoteModel.findById(sampleData.note_id).lean().exec().catch(err => {next(err);}) as any; + if (data instanceof Error) return; + newNotes = !_.isEqual(_.pick(IdValidate.stringify(data), _.keys(sample.notes)), sample.notes); // check if notes were changed + if (newNotes) { + if (data.hasOwnProperty('custom_fields')) { // update note_fields + customFieldsChange(Object.keys(data.custom_fields), -1, req); + } + await NoteModel.findByIdAndDelete(sampleData.note_id).log(req).lean().exec(err => { // delete old notes + if (err) return console.error(err); + }); + } + } + + if (_.keys(sample.notes).length > 0 && newNotes) { // save new notes + if (!await sampleRefCheck(sample, res, next)) return; + if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields + customFieldsChange(Object.keys(sample.notes.custom_fields), 1, req); + } + let data = await new NoteModel(sample.notes).save().catch(err => { return next(err)}); // save new notes + db.log(req, 'notes', {_id: data._id}, data.toObject()); + delete sample.notes; + sample.note_id = data._id; + } + } + + // check for changes + if (!_.isEqual(_.pick(IdValidate.stringify(sampleData), _.keys(sample)), _.omit(sample, ['notes']))) { + sample.status = globals.status.new; + } + + await SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).log(req).lean().exec((err, data: any) => { + if (err) return next(err); + res.json(SampleValidate.output(data)); + }); + + }); +}); + +router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + SampleModel.findById(req.params.id).lean().exec(async (err, sampleData: any) => { // check if id exists + if (err) return next(err); + if (!sampleData) { + return res.status(404).json({status: 'Not found'}); + } + + // only maintain and admin are allowed to edit other user's data + if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; + + await SampleModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).log(req).lean().exec(err => { // set sample status + if (err) return next(err); + + // set status of associated measurements also to deleted + MeasurementModel.updateMany({sample_id: mongoose.Types.ObjectId(req.params.id)}, {status: -1}).log(req).lean().exec(err => { + if (err) return next(err); + + if (sampleData.note_id !== null) { // handle notes + NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { // find notes to update note_fields + if (err) return next(err); + if (data.hasOwnProperty('custom_fields')) { // update note_fields + customFieldsChange(Object.keys(data.custom_fields), -1, req); + } + res.json({status: 'OK'}); + }); + } + else { + res.json({status: 'OK'}); + } + }); + }); + }); +}); + +router.put('/sample/restore/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.new}).log(req).lean().exec((err, data) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + res.json({status: 'OK'}); + }); +}); + +router.put('/sample/validate/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + SampleModel.findById(req.params.id).lean().exec((err, data: any) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + if (Object.keys(data.condition).length === 0) { + return res.status(400).json({status: 'Sample without condition cannot be valid'}); + } + + MeasurementModel.find({sample_id: mongoose.Types.ObjectId(req.params.id)}).lean().exec((err, data) => { + if (err) return next(err); + + if (data.length === 0) { + return res.status(400).json({status: 'Sample without measurements cannot be valid'}); + } + + SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.validated}).log(req).lean().exec(err => { + if (err) return next(err); + res.json({status: 'OK'}); + }); + }); + }); +}); + +router.post('/sample/new', async (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + if (!req.body.hasOwnProperty('condition')) { // add empty condition if not specified + req.body.condition = {}; + } + + const {error, value: sample} = SampleValidate.input(req.body, 'new' + (req.authDetails.level === 'admin' ? '-admin' : '')); + if (error) return res400(error, res); + + if (!await materialCheck(sample, res, next)) return; + if (!await sampleRefCheck(sample, res, next)) return; + + if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields + customFieldsChange(Object.keys(sample.notes.custom_fields), 1, req); + } + + if (!_.isEmpty(sample.condition)) { // do not execute check if condition is empty + if (!await conditionCheck(sample.condition, 'change', res, next)) return; + } + + sample.status = globals.status.new; // set status to new + if (sample.hasOwnProperty('number')) { + if (!await numberCheck(sample, res, next)) return; + } + else { + sample.number = await numberGenerate(sample, req, res, next); + } + if (!sample.number) return; + + await new NoteModel(sample.notes).save((err, data) => { // save notes + if (err) return next(err); + db.log(req, 'notes', {_id: data._id}, data.toObject()); + delete sample.notes; + sample.note_id = data._id; + sample.user_id = req.authDetails.id; + + new SampleModel(sample).save((err, data) => { + if (err) return next(err); + db.log(req, 'samples', {_id: data._id}, data.toObject()); + res.json(SampleValidate.output(data.toObject())); + }); + }); +}); + +router.get('/sample/notes/fields', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + NoteFieldModel.find({}).lean().exec((err, data) => { + if (err) return next(err); + res.json(_.compact(data.map(e => NoteFieldValidate.output(e)))); // validate all and filter null values from validation errors + }) +}); + + +module.exports = router; + + +async function numberGenerate (sample, req, res, next) { // generate number in format Location32, returns false on error + const sampleData = await SampleModel + // .findOne({number: new RegExp('^' + req.authDetails.location + '[0-9]+$', 'm')}) + // .sort({number: -1}) + // .lean() + .aggregate([ + {$match: {number: new RegExp('^' + 'Rng' + '[0-9]+$', 'm')}}, + // {$addFields: {number2: {$toDecimal: {$arrayElemAt: [{$split: [{$arrayElemAt: [{$split: ['$number', 'Rng']}, 1]}, '_']}, 0]}}}}, // not working with MongoDb 3.6 + {$addFields: {sortNumber: {$let: { + vars: {tmp: {$concat: ['000000000000000000000000000000', {$arrayElemAt: [{$split: [{$arrayElemAt: [{$split: ['$number', 'Rng']}, 1]}, '_']}, 0]}]}}, + in: {$substrCP: ['$$tmp', {$subtract: [{$strLenCP: '$$tmp'}, 30]}, {$strLenCP: '$$tmp'}]} + }}}}, + {$sort: {sortNumber: -1}}, + {$limit: 1} + ]) + .exec() + .catch(err => next(err)); + if (sampleData instanceof Error) return false; + return req.authDetails.location + (sampleData[0] ? Number(sampleData[0].number.replace(/[^0-9]+/g, '')) + 1 : 1); +} + +async function numberCheck(sample, res, next) { + const sampleData = await SampleModel.findOne({number: sample.number}).lean().exec().catch(err => {next(err); return false;}); + if (sampleData) { // found entry with sample number + res.status(400).json({status: 'Sample number already taken'}); + return false + } + return true; +} + +async function materialCheck (sample, res, next, id = sample.material_id) { // validate material_id and color, returns false if invalid + const materialData = await MaterialModel.findById(id).lean().exec().catch(err => next(err)) as any; + if (materialData instanceof Error) return false; + if (!materialData) { // could not find material_id + res.status(400).json({status: 'Material not available'}); + return false; + } + if (sample.hasOwnProperty('color') && sample.color !== '' && !materialData.numbers.find(e => e.color === sample.color)) { // color for material not specified + res.status(400).json({status: 'Color not available for material'}); + return false; + } + return true; +} + +async function conditionCheck (condition, param, res, next, checkVersion = true) { // validate treatment template, returns false if invalid, otherwise template data + if (!condition.condition_template || !IdValidate.valid(condition.condition_template)) { // template id not found + res.status(400).json({status: 'Condition template not available'}); + return false; + } + const conditionData = await ConditionTemplateModel.findById(condition.condition_template).lean().exec().catch(err => next(err)) as any; + if (conditionData instanceof Error) return false; + if (!conditionData) { // template not found + res.status(400).json({status: 'Condition template not available'}); + return false; + } + + if (checkVersion) { + // get all template versions and check if given is latest + const conditionVersions = await ConditionTemplateModel.find({first_id: conditionData.first_id}).sort({version: -1}).lean().exec().catch(err => next(err)) as any; + if (conditionVersions instanceof Error) return false; + if (condition.condition_template !== conditionVersions[0]._id.toString()) { // template not latest + res.status(400).json({status: 'Old template version not allowed'}); + return false; + } + } + + // validate parameters + const {error, value: ignore} = ParametersValidate.input(_.omit(condition, 'condition_template'), conditionData.parameters, param); + if (error) {res400(error, res); return false;} + return conditionData; +} + +function sampleRefCheck (sample, res, next) { // validate sample_references, resolves false for invalid reference + return new Promise(resolve => { + if (sample.notes.hasOwnProperty('sample_references') && sample.notes.sample_references.length > 0) { // there are sample_references + let referencesCount = sample.notes.sample_references.length; // count to keep track of running async operations + + sample.notes.sample_references.forEach(reference => { + SampleModel.findById(reference.sample_id).lean().exec((err, data) => { + if (err) {next(err); resolve(false)} + if (!data) { + res.status(400).json({status: 'Sample reference not available'}); + return resolve(false); + } + referencesCount --; + if (referencesCount <= 0) { // all async requests done + resolve(true); + } + }); + }); + } + else { + resolve(true); + } + }); +} + +function customFieldsChange (fields, amount, req) { // update custom_fields and respective quantities + fields.forEach(field => { + NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: amount}} as any, {new: true}).log(req).lean().exec((err, data: any) => { // check if field exists + if (err) return console.error(err); + if (!data) { // new field + new NoteFieldModel({name: field, qty: 1}).save((err, data) => { + if (err) return console.error(err); + db.log(req, 'note_fields', {_id: data._id}, data.toObject()); + }) + } + else if (data.qty <= 0) { // delete document if field is not used anymore + NoteFieldModel.findOneAndDelete({name: field}).log(req).lean().exec(err => { + if (err) return console.error(err); + }); + } + }); + }); +} + +function sortQuery(filters, sortKeys, sortStartValue) { // sortKeys = ['primary key', 'secondary key'] + if (filters['from-id']) { // from-id specified + if ((filters['to-page'] === 0 && filters.sort[1] === 1) || (filters.sort[1] * filters['to-page'] > 0)) { // asc + return [{$match: {$or: [{[sortKeys[0]]: {$gt: sortStartValue}}, {$and: [{[sortKeys[0]]: sortStartValue}, {[sortKeys[1]]: {$gte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}}, + {$sort: {[sortKeys[0]]: 1, _id: 1}}]; + } else { + return [{$match: {$or: [{[sortKeys[0]]: {$lt: sortStartValue}}, {$and: [{[sortKeys[0]]: sortStartValue}, {[sortKeys[1]]: {$lte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}}, + {$sort: {[sortKeys[0]]: -1, _id: -1}}]; + } + } else { // sort from beginning + return [{$sort: {[sortKeys[0]]: filters.sort[1], [sortKeys[1]]: filters.sort[1]}}]; // set _id as secondary sort + } +} + +function statusQuery(filters, field) { + if (filters.hasOwnProperty('status')) { + if(filters.status === 'all') { + return {$or: [{[field]: globals.status.validated}, {[field]: globals.status.new}]}; + } + else { + return {[field]: globals.status[filters.status]}; + } + } + else { // default + return {[field]: globals.status.validated}; + } +} + +function addFilterQueries (queryPtr, filters) { // returns array of match queries from given filters + if (filters.length) { + queryPtr.push({$match: {$and: filterQueries(filters)}}); + } +} + +function filterQueries (filters) { + console.log(filters); + return filters.map(e => { + if (e.mode === 'or') { // allow or queries (needed for $ne added) + return {['$' + e.mode]: e.values}; + } + else { + return {[e.field]: {['$' + e.mode]: (e.mode.indexOf('in') >= 0 ? e.values : e.values[0])}}; // add filter criteria as {field: {$mode: value}}, only use first value when mode is not in/nin + } + }); +} + +function dateToOId (date) { // convert date to ObjectId + return mongoose.Types.ObjectId(Math.floor(date / 1000).toString(16) + '0000000000000000'); +} \ No newline at end of file diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts new file mode 100644 index 0000000..cd90108 --- /dev/null +++ b/src/routes/template.spec.ts @@ -0,0 +1,898 @@ +import should from 'should/as-function'; +import _ from 'lodash'; +import TemplateConditionModel from '../models/condition_template'; +import TemplateMeasurementModel from '../models/measurement_template'; +import TestHelper from "../test/helper"; + + +describe('/template', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); + + describe('/template/condition', () => { + describe('GET /template/conditions', () => { + it('returns all condition templates', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/conditions', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.condition_templates.length); + should(res.body).matchEach(condition => { + should(condition).have.only.keys('_id', 'name', 'version', 'parameters'); + should(condition).have.property('_id').be.type('string'); + should(condition).have.property('name').be.type('string'); + should(condition).have.property('version').be.type('number'); + should(condition.parameters).matchEach(number => { + should(number).have.only.keys('name', 'range'); + should(number).have.property('name').be.type('string'); + should(number).have.property('range').be.type('object'); + }); + }); + done(); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/conditions', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/conditions', + httpStatus: 401 + }); + }); + }); + + describe('GET /template/condition/{id}', () => { + it('returns the right condition template', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/condition/200000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/condition/000000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/condition/200000000000000000000001', + httpStatus: 401 + }); + }); + }); + + describe('PUT /template/condition/{name}', () => { + it('returns the right condition template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + }); + }); + it('keeps unchanged properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]}, + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + }); + }); + it('keeps only one unchanged property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat treatment'}, + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + }); + }); + it('changes the given properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} + }).end((err, res) => { + if (err) return done(err); + TemplateConditionModel.findById(res.body._id).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); + should(data.first_id.toString()).be.eql('200000000000000000000001'); + should(data).have.property('name', 'heat aging'); + should(data).have.property('version', 2); + should(data).have.property('parameters').have.lengthOf(1); + should(data.parameters[0]).have.property('name', 'time'); + should(data.parameters[0]).have.property('range'); + should(data.parameters[0].range).have.property('min', 1); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}, + log: { + collection: 'condition_templates', + dataAdd: { + first_id: '200000000000000000000001', + version: 2 + } + } + }); + }); + it('allows changing only one property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging'} + }).end((err, res) => { + if (err) return done(err); + TemplateConditionModel.findById(res.body._id).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); + should(data.first_id.toString()).be.eql('200000000000000000000001'); + should(data).have.property('name', 'heat aging'); + should(data).have.property('version', 2); + should(data).have.property('parameters').have.lengthOf(2); + should(data.parameters[0]).have.property('name', 'material'); + should(data.parameters[1]).have.property('name', 'weeks'); + done(); + }); + }); + }); + it('supports values ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'time', range: {values: [1, 2, 5]}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, parameters: [{name: 'time', range: {values: [1, 2, 5]}}]}); + done(); + }); + }); + it('supports min max ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'time', range: {min: 1, max: 11}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, parameters: [{name: 'time', range: {min: 1, max: 11}}]}); + done(); + }); + }); + it('supports array type ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'time', range: {type: 'array'}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, parameters: [{name: 'time', range: {type: 'array'}}]}); + done(); + }); + }); + it('supports empty ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'time', range: {}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, parameters: [{name: 'time', range: {}}]}); + done(); + }); + }); + it('rejects `condition_template` as parameter name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {parameters: [{name: 'condition_template', range: {}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].name" contains an invalid value'} + }); + }); + it('rejects not specified parameters', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat treatment', parameters: [{name: 'material', range: {xx: 5}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/2000000000h0000000000001', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/000000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('POST /template/condition/new', () => { + it('returns the right condition template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat treatment3', parameters: [{name: 'material', range: {values: ['copper']}}]} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'name', 'version', 'parameters'); + should(res.body).have.property('name', 'heat treatment3'); + should(res.body).have.property('version', 1); + should(res.body).have.property('parameters').have.lengthOf(1); + should(res.body.parameters[0]).have.property('name', 'material'); + should(res.body.parameters[0]).have.property('range'); + should(res.body.parameters[0].range).have.property('values'); + should(res.body.parameters[0].range.values[0]).be.eql('copper'); + done(); + }); + }); + it('stores the template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} + }).end((err, res) => { + if (err) return done(err); + TemplateConditionModel.findById(res.body._id).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); + should(data.first_id.toString()).be.eql(data._id.toString()); + should(data).have.property('name', 'heat aging'); + should(data).have.property('version', 1); + should(data).have.property('parameters').have.lengthOf(1); + should(data.parameters[0]).have.property('name', 'time'); + should(data.parameters[0]).have.property('range'); + should(data.parameters[0].range).have.property('min', 1); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}, + log: { + collection: 'condition_templates', + dataAdd: {version: 1}, + dataIgn: ['first_id'] + } + }); + }); + it('rejects a missing name', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {parameters: [{name: 'time', range: {min: 1}}]}, + res: {status: 'Invalid body format', details: '"name" is required'} + }); + }); + it('rejects `condition_template` as parameter name', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', parameters: [{name: 'condition_template', range: {min: 1}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].name" contains an invalid value'} + }); + }); + it('rejects a number prefix', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {min: 1}}]}, + res: {status: 'Invalid body format', details: '"number_prefix" is not allowed'} + }); + }); + it('rejects missing parameters', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging'}, + res: {status: 'Invalid body format', details: '"parameters" is required'} + }); + }); + it('rejects a missing parameter name', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', parameters: [{range: {min: 1}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].name" is required'} + }); + }); + it('rejects a missing parameter range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', parameters: [{name: 'time'}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} + }); + }); + it('rejects an invalid parameter range property', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', parameters: [{name: 'time', range: {xx: 1}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'} + }); + }); + it('rejects wrong properties', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', parameters: [{name: 'time', range: {}}], xx: 33}, + res: {status: 'Invalid body format', details: '"xx" is not allowed'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {key: 'admin'}, + httpStatus: 401, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + httpStatus: 401, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} + }); + }); + }); + }); + + describe('/template/measurement', () => { + describe('GET /template/measurements', () => { + it('returns all measurement templates', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurements', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.measurement_templates.length); + should(res.body).matchEach(measurement => { + should(measurement).have.only.keys('_id', 'name', 'version', 'parameters'); + should(measurement).have.property('_id').be.type('string'); + should(measurement).have.property('name').be.type('string'); + should(measurement).have.property('version').be.type('number'); + should(measurement.parameters).matchEach(number => { + should(number).have.only.keys('name', 'range'); + should(number).have.property('name').be.type('string'); + should(number).have.property('range').be.type('object'); + }); + }); + done(); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurements', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurements', + httpStatus: 401 + }); + }); + }); + + describe('GET /template/measurement/id', () => { + it('returns the right measurement template', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: { type: 'array'}}]} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurement/300000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurement/000000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurement/300000000000000000000001', + httpStatus: 401 + }); + }); + }); + + describe('PUT /template/measurement/{name}', () => { + it('returns the right measurement template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: { type: 'array'}}]} + }); + }); + it('keeps unchanged properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'spectrum', parameters: [{name: 'dpt', range: { type: 'array'}}]}, + res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: {type: 'array'}}]} + }); + }); + it('keeps only one unchanged property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'spectrum'}, + res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: {type: 'array'}}]} + }); + }); + it('changes the given properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'IR spectrum', version: 2, parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}); + TemplateMeasurementModel.findById(res.body._id).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); + should(data.first_id.toString()).be.eql('300000000000000000000001'); + should(data).have.property('name', 'IR spectrum'); + should(data).have.property('version', 2); + should(data).have.property('parameters').have.lengthOf(1); + should(data.parameters[0]).have.property('name', 'data point table'); + should(data.parameters[0]).have.property('range'); + should(data.parameters[0].range).have.property('min', 0); + should(data.parameters[0].range).have.property('max', 1000); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, + log: { + collection: 'measurement_templates', + dataAdd: { + first_id: '300000000000000000000001', + version: 2 + } + } + }); + }); + it('allows changing only one property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'IR spectrum'}, + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'IR spectrum', version: 2, parameters: [{name: 'dpt', range: {type: 'array'}}]}); + TemplateMeasurementModel.findById(res.body._id).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); + should(data.first_id.toString()).be.eql('300000000000000000000001'); + should(data).have.property('name', 'IR spectrum'); + should(data).have.property('version', 2); + should(data).have.property('parameters').have.lengthOf(1); + should(data.parameters[0]).have.property('name', 'dpt'); + should(data.parameters[0]).have.property('range'); + should(data.parameters[0].range).have.property('type', 'array'); + done(); + }); + }); + }); + it('supports values ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'spectrum', version: 2, parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]}); + done(); + }); + }); + it('supports min max ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'spectrum', version: 2, parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]}); + done(); + }); + }); + it('supports array type ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'dpt2', range: {type: 'array'}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'spectrum', version: 2, parameters: [{name: 'dpt2', range: {type: 'array'}}]}); + done(); + }); + }); + it('supports empty ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000002', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'weight %', range: {}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'kf', version: 2, parameters: [{name: 'weight %', range: {}}]}); + done(); + }); + }); + it('rejects not specified parameters', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {parameters: [{name: 'dpt'}], range: {xx: 33}}, + res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/3000000000h0000000000001', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/000000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('POST /template/measurement/new', () => { + it('returns the right measurement template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'name', 'version', 'parameters'); + should(res.body).have.property('name', 'vz'); + should(res.body).have.property('version', 1); + should(res.body).have.property('parameters').have.lengthOf(1); + should(res.body.parameters[0]).have.property('name', 'vz'); + should(res.body.parameters[0]).have.property('range'); + should(res.body.parameters[0].range).have.property('min', 1); + done(); + }); + }); + it('stores the template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]} + }).end(err => { + if (err) return done(err); + TemplateMeasurementModel.find({name: 'vz'}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); + should(data[0].first_id.toString()).be.eql(data[0]._id.toString()); + should(data[0]).have.property('name', 'vz'); + should(data[0]).have.property('version', 1); + should(data[0]).have.property('parameters').have.lengthOf(1); + should(data[0].parameters[0]).have.property('name', 'vz'); + should(data[0].parameters[0]).have.property('range'); + should(data[0].parameters[0].range).have.property('min', 1); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]}, + log: { + collection: 'measurement_templates', + dataAdd: {version: 1}, + dataIgn: ['first_id'] + } + }); + }); + it('rejects a missing name', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, + res: {status: 'Invalid body format', details: '"name" is required'} + }); + }); + it('rejects missing parameters', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum'}, + res: {status: 'Invalid body format', details: '"parameters" is required'} + }); + }); + it('rejects a missing parameter name', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum', parameters: [{range: {min: 0, max: 1000}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].name" is required'} + }); + }); + it('rejects a missing parameter range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum', parameters: [{name: 'data point table'}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} + }); + }); + it('rejects a an invalid parameter range property', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {xx: 0}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'} + }); + }); + it('rejects wrong properties', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {}}], xx: 35}, + res: {status: 'Invalid body format', details: '"xx" is not allowed'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {key: 'admin'}, + httpStatus: 401, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]} + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + httpStatus: 401, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]} + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/template.ts b/src/routes/template.ts new file mode 100644 index 0000000..c3bd14b --- /dev/null +++ b/src/routes/template.ts @@ -0,0 +1,86 @@ +import express from 'express'; +import _ from 'lodash'; + +import TemplateValidate from './validate/template'; +import ConditionTemplateModel from '../models/condition_template'; +import MeasurementTemplateModel from '../models/measurement_template'; +import res400 from './validate/res400'; +import IdValidate from './validate/id'; +import mongoose from "mongoose"; +import db from '../db'; + + + +const router = express.Router(); + +router.get('/template/:collection(measurements|conditions)', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; + + req.params.collection = req.params.collection.replace(/s$/g, ''); // remove trailing s + model(req).find({}).lean().exec((err, data) => { + if (err) next (err); + res.json(_.compact(data.map(e => TemplateValidate.output(e)))); // validate all and filter null values from validation errors + }); +}); + +router.get('/template/:collection(measurement|condition)/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; + + model(req).findById(req.params.id).lean().exec((err, data) => { + if (err) next (err); + if (data) { + res.json(TemplateValidate.output(data)); + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + +router.put('/template/:collection(measurement|condition)/' + IdValidate.parameter(), async (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + const {error, value: template} = TemplateValidate.input(req.body, 'change'); + if (error) return res400(error, res); + + const templateData = await model(req).findById(req.params.id).lean().exec().catch(err => {next(err);}) as any; + if (templateData instanceof Error) return; + if (!templateData) { + return res.status(404).json({status: 'Not found'}); + } + + if (!_.isEqual(_.pick(templateData, _.keys(template)), template)) { // data was changed + template.version = templateData.version + 1; // increase version + await new (model(req))(_.assign({}, _.omit(templateData, ['_id', '__v']), template)).save((err, data) => { // save new template, fill with old properties + if (err) next (err); + db.log(req, req.params.collection + '_templates', {_id: data._id}, data.toObject()); + res.json(TemplateValidate.output(data.toObject())); + }); + } + else { + res.json(TemplateValidate.output(templateData)); + } +}); + +router.post('/template/:collection(measurement|condition)/new', async (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + const {error, value: template} = TemplateValidate.input(req.body, 'new'); + if (error) return res400(error, res); + + template._id = mongoose.Types.ObjectId(); // set reference to itself for first version of template + template.first_id = template._id; + template.version = 1; // set template version + await new (model(req))(template).save((err, data) => { + if (err) next (err); + db.log(req, req.params.collection + '_templates', {_id: data._id}, data.toObject()); + res.json(TemplateValidate.output(data.toObject())); + }); +}); + + +module.exports = router; + +function model (req) { // return right template model + return req.params.collection === 'condition' ? ConditionTemplateModel : MeasurementTemplateModel; +} \ No newline at end of file diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts new file mode 100644 index 0000000..79c0769 --- /dev/null +++ b/src/routes/user.spec.ts @@ -0,0 +1,677 @@ +import should from 'should/as-function'; +import UserModel from '../models/user'; +import TestHelper from "../test/helper"; + + + +describe('/user', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); + + describe('GET /users', () => { + it('returns all users', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/users', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.users.length); + should(res.body).matchEach(user => { + should(user).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + should(user).have.property('_id').be.type('string'); + should(user).have.property('email').be.type('string'); + should(user).have.property('name').be.type('string'); + should(user).have.property('level').be.type('string'); + should(user).have.property('location').be.type('string'); + should(user).have.property('device_name').be.type('string'); + }); + done(); + }); + }); + it('rejects requests from non-admins', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/users', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects requests from an admin API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/users', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/users', + httpStatus: 401 + }); + }); + }); + + describe('GET /user/{name}', () => { + it('returns own user details', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('email', 'jane.doe@bosch.com'); + should(res.body).have.property('name', 'janedoe'); + should(res.body).have.property('level', 'write'); + should(res.body).have.property('location', 'Rng'); + should(res.body).have.property('device_name', 'Alpha I'); + done(); + }); + }); + it('returns other user details for admin', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/janedoe', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('email', 'jane.doe@bosch.com'); + should(res.body).have.property('name', 'janedoe'); + should(res.body).have.property('level', 'write'); + should(res.body).have.property('location', 'Rng'); + should(res.body).have.property('device_name', 'Alpha I'); + done(); + }); + }); + it('rejects requests from non-admins for another user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/admin', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects requests from a user API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects requests from an admin API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/janedoe', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('returns 404 for an unknown user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/unknown', + auth: {basic: 'admin'}, + httpStatus: 404 + }); + }); + it('rejects requests from an admin API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/janedoe', + httpStatus: 401 + }); + }); + }); + + describe('PUT /user/{name}', () => { + it('returns own user details', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('email', 'jane.doe@bosch.com'); + should(res.body).have.property('name', 'janedoe'); + should(res.body).have.property('level', 'write'); + should(res.body).have.property('location', 'Rng'); + should(res.body).have.property('device_name', 'Alpha I'); + done(); + }); + }); + it('returns other user details for admin', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user/janedoe', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('email', 'jane.doe@bosch.com'); + should(res.body).have.property('name', 'janedoe'); + should(res.body).have.property('level', 'write'); + should(res.body).have.property('location', 'Rng'); + should(res.body).have.property('device_name', 'Alpha I'); + done(); + }); + }); + it('changes user details as given', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'adminnew', email: 'adminnew@bosch.com', pass: 'Abc123##', location: 'Abt', device_name: 'test'} + }).end(err => { + if (err) return done (err); + UserModel.find({name: 'adminnew'}).lean().exec( (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'pass', 'email', 'level', 'location', 'device_name', 'key', '__v'); + should(data[0]).have.property('_id'); + should(data[0]).have.property('name', 'adminnew'); + should(data[0]).have.property('email', 'adminnew@bosch.com'); + should(data[0]).have.property('pass').not.eql('Abc123##'); + should(data[0]).have.property('level', 'admin'); + should(data[0]).have.property('location', 'Abt'); + should(data[0]).have.property('device_name', 'test'); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'adminnew', email: 'adminnew@bosch.com', pass: 'Abc123##', location: 'Abt', device_name: 'test'}, + log: { + collection: 'users', + dataIgn: ['pass'] + } + }); + }); + it('lets the admin change a user level', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user/janedoe', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {level: 'read'} + }).end(err => { + if (err) return done (err); + UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.property('level', 'read'); + done(); + }); + }); + }); + it('does not change the level', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'janedoe'}, + httpStatus: 400, default: false, + req: {level: 'read'} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'Invalid body format', details: '"level" is not allowed'}); + UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.property('level', 'write'); + done(); + }); + }); + }); + it('rejects a username already in use', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'admin'}, + httpStatus: 400, default: false, + req: {name: 'janedoe'} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'Username already taken'}); + UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + done(); + }); + }); + }); + it('rejects a username which is in the special names', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 400, default: false, + req: {email: 'j.doe@bosch.com', name: 'passreset', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}, + res: {status: 'Username already taken'} + }); + }); + it('rejects invalid user details', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', location: 44, device_name: 'Alpha II'}, + res: {status: 'Invalid body format', details: '"location" must be a string'} + }); + }); + it('rejects an invalid email address', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {email: 'john.doe'}, + res: {status: 'Invalid body format', details: '"email" must be a valid email'} + }); + }); + it('rejects an invalid password', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {pass: 'password'}, + res: {status: 'Invalid body format', details: '"pass" with value "password" fails to match the required pattern: /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$)[a-zA-Z0-9!"#%&\'()*+,\\-.\\/:;<=>?@[\\]^_`{|}~]{8,}$/'} + }); + }); + it('rejects requests from non-admins for another user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user/admin', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('rejects requests from a user API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects requests from an admin API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user/janedoe', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('returns 404 for an unknown user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user/unknown', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user/janedoe', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('DELETE /user/{name}', () => { + it('deletes own user details', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(0); + done(); + }); + }); + }); + it('deletes other user details for admin', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user/janedoe', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(0); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user', + auth: {basic: 'janedoe'}, + httpStatus: 200, + log: { + collection: 'users' + } + }); + }); + it('rejects requests from non-admins for another user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user/admin', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects requests from a user API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects requests from an admin API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user/janedoe', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('returns 404 for an unknown user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user/unknown', + auth: {basic: 'admin'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user/janedoe', + httpStatus: 401 + }); + }); + }); + + describe('GET /user/key', () => { + it('returns the right API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/key', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {key: TestHelper.auth.janedoe.key} + }); + }); + it('rejects requests from an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/key', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects requests from an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/key', + httpStatus: 401 + }); + }); + }); + + describe('POST /user/new', () => { + it('returns the added user data', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('email', 'john.doe@bosch.com'); + should(res.body).have.property('name', 'johndoe'); + should(res.body).have.property('level', 'read'); + should(res.body).have.property('location', 'Rng'); + should(res.body).have.property('device_name', 'Alpha II'); + done(); + }); + }); + it('stores the data', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'} + }).end(err => { + if (err) return done (err); + UserModel.find({name: 'johndoe'}).lean().exec( (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'pass', 'email', 'level', 'location', 'device_name', 'key', '__v'); + should(data[0]).have.property('_id'); + should(data[0]).have.property('name', 'johndoe'); + should(data[0]).have.property('email', 'john.doe@bosch.com'); + should(data[0]).have.property('pass').not.eql('Abc123!#'); + should(data[0]).have.property('level', 'read'); + should(data[0]).have.property('location', 'Rng'); + should(data[0]).have.property('device_name', 'Alpha II'); + done(); + }); + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}, + log: { + collection: 'users', + dataIgn: ['pass', 'key'] + } + }); + }); + it('rejects a username already in use', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 400, default: false, + req: {email: 'j.doe@bosch.com', name: 'janedoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'Username already taken'}); + UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + done(); + }); + }); + }); + it('rejects a username which is in the special names', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 400, default: false, + req: {email: 'j.doe@bosch.com', name: 'passreset', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}, + res: {status: 'Username already taken'} + }); + }); + it('rejects invalid user details', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 44, device_name: 'Alpha II'}, + res: {status: 'Invalid body format', details: '"location" must be a string'} + }); + }); + it('rejects an invalid user level', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'xxx', location: 'Rng', device_name: 'Alpha II'}, + res: {status: 'Invalid body format', details: '"level" must be one of [read, write, maintain, dev, admin]'} + }); + }); + it('rejects an invalid email address', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {email: 'john.doe', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}, + res: {status: 'Invalid body format', details: '"email" must be a valid email'} + }); + }); + it('rejects an invalid password', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'password', level: 'read', location: 'Rng', device_name: 'Alpha II'}, + res: {status: 'Invalid body format', details: '"pass" with value "password" fails to match the required pattern: /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$)[a-zA-Z0-9!"#%&\'()*+,\\-.\\/:;<=>?@[\\]^_`{|}~]{8,}$/'} + }); + }); + it('rejects requests from non-admins', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'} + }); + }); + it('rejects requests from an admin API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {key: 'admin'}, + httpStatus: 401, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + httpStatus: 401, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'} + }); + }); + }); + + describe('POST /user/passreset', () => { + it('returns the ok response', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/passreset', + httpStatus: 200, + req: {email: 'jane.doe@bosch.com', name: 'janedoe'}, + res: {status: 'OK'} + }); + }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/passreset', + httpStatus: 200, + req: {email: 'jane.doe@bosch.com', name: 'janedoe'}, + log: { + collection: 'users', + dataIgn: ['email', 'name', 'pass'] + } + }); + }); + it('returns 404 for wrong username/email combo', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/passreset', + httpStatus: 404, + req: {email: 'jane.doe@bosch.com', name: 'admin'} + }); + }); + it('returns 404 for unknown username', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/passreset', + httpStatus: 404, + req: {email: 'jane.doe@bosch.com', name: 'username'} + }); + }); + it('changes the user password', done => { + UserModel.find({name: 'janedoe'}).lean().exec( (err, data: any) => { + if (err) return done(err); + const oldpass = data[0].pass; + TestHelper.request(server, done, { + method: 'post', + url: '/user/passreset', + httpStatus: 200, + req: {email: 'jane.doe@bosch.com', name: 'janedoe'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + UserModel.find({name: 'janedoe'}).lean().exec( (err, data: any) => { + if (err) return done(err); + should(data[0].pass).not.eql(oldpass); + done(); + }); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts new file mode 100644 index 0000000..65c41d5 --- /dev/null +++ b/src/routes/user.ts @@ -0,0 +1,163 @@ +import express from 'express'; +import mongoose from 'mongoose'; +import bcrypt from 'bcryptjs'; +import _ from 'lodash'; + +import UserValidate from './validate/user'; +import UserModel from '../models/user'; +import mail from '../helpers/mail'; +import res400 from './validate/res400'; +import db from '../db'; + +const router = express.Router(); + + +router.get('/users', (req, res) => { + if (!req.auth(res, ['admin'], 'basic')) return; + + UserModel.find({}).lean().exec( (err, data:any) => { + res.json(_.compact(data.map(e => UserValidate.output(e)))); // validate all and filter null values from validation errors + }); +}); + +router.get('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new. See https://forbeslindesay.github.io/express-route-tester/ for the generated regex + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const username = getUsername(req, res); + if (!username) return; + UserModel.findOne({name: username}).lean().exec( (err, data:any) => { + if (err) return next(err); + if (data) { + res.json(UserValidate.output(data)); // validate all and filter null values from validation errors + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + +router.put('/user:username([/](?!key|new).?*|/?)', async (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const username = getUsername(req, res); + if (!username) return; + + const {error, value: user} = UserValidate.input(req.body, 'change' + (req.authDetails.level === 'admin'? 'admin' : '')); + if (error) return res400(error, res); + + if (user.hasOwnProperty('pass')) { + user.pass = bcrypt.hashSync(user.pass, 10); + } + + // check that user does not already exist if new name was specified + if (user.hasOwnProperty('name') && user.name !== username) { + if (!await usernameCheck(user.name, res, next)) return; + } + + await UserModel.findOneAndUpdate({name: username}, user, {new: true}).log(req).lean().exec( (err, data:any) => { + if (err) return next(err); + if (data) { + res.json(UserValidate.output(data)); + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + +router.delete('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new. See https://forbeslindesay.github.io/express-route-tester/ for the generated regex + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const username = getUsername(req, res); + if (!username) return; + + UserModel.findOneAndDelete({name: username}).log(req).lean().exec( (err, data:any) => { + if (err) return next(err); + if (data) { + res.json({status: 'OK'}) + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + +router.get('/user/key', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; + + UserModel.findOne({name: req.authDetails.username}).lean().exec( (err, data:any) => { + if (err) return next(err); + res.json({key: data.key}); + }); +}); + +router.post('/user/new', async (req, res, next) => { + if (!req.auth(res, ['admin'], 'basic')) return; + + // validate input + const {error, value: user} = UserValidate.input(req.body, 'new'); + if (error) return res400(error, res); + + // check that user does not already exist + if (!await usernameCheck(user.name, res, next)) return; + + user.key = mongoose.Types.ObjectId(); // use object id as unique API key + bcrypt.hash(user.pass, 10, (err, hash) => { // password hashing + user.pass = hash; + new UserModel(user).save((err, data) => { // store user + if (err) return next(err); + db.log(req, 'users', {_id: data._id}, data.toObject()); + res.json(UserValidate.output(data.toObject())); + }); + }); +}); + +router.post('/user/passreset', (req, res, next) => { + // check if user/email combo exists + UserModel.find({name: req.body.name, email: req.body.email}).lean().exec( (err, data: any) => { + if (err) return next(err); + if (data.length === 1) { // it exists + const newPass = Math.random().toString(36).substring(2); // generate temporary password + bcrypt.hash(newPass, 10, (err, hash) => { // password hashing + if (err) return next(err); + + UserModel.findByIdAndUpdate(data[0]._id, {pass: hash}).log(req).exec(err => { // write new password + if (err) return next(err); + + // send email + mail(data[0].email, 'Your new password for the DFOP database', 'Hi,

You requested to reset your password.
Your new password is:

' + newPass + '

If you did not request a password reset, talk to the sysadmin quickly!

Have a nice day.

The DFOP team', err => { + if (err) return next(err); + res.json({status: 'OK'}); + }); + }); + }); + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + + +module.exports = router; + +function getUsername (req, res) { // returns username or false if action is not allowed + req.params.username = req.params[0]; // because of path regex + if (req.params.username !== undefined) { // different username than request user + if (!req.auth(res, ['admin'], 'basic')) return false; + return req.params.username; + } + else { + return req.authDetails.username; + } +} + +async function usernameCheck (name, res, next) { // check if username is already taken + const userData = await UserModel.findOne({name: name}).lean().exec().catch(err => next(err)) as any; + if (userData instanceof Error) return false; + if (userData || UserValidate.isSpecialName(name)) { + res.status(400).json({status: 'Username already taken'}); + return false; + } + return true; +} \ No newline at end of file diff --git a/src/routes/validate/id.ts b/src/routes/validate/id.ts new file mode 100644 index 0000000..6b7b677 --- /dev/null +++ b/src/routes/validate/id.ts @@ -0,0 +1,29 @@ +import Joi from '@hapi/joi'; + +export default class IdValidate { + private static id = Joi.string().pattern(new RegExp('[0-9a-f]{24}')).length(24); + + static get () { // return joi validation + return this.id; + } + + static valid (id) { // validate id + return this.id.validate(id).error === undefined; + } + + static parameter () { // :id url parameter + return ':id([0-9a-f]{24})'; + } + + static stringify (data) { // convert all ObjectID objects to plain strings + Object.keys(data).forEach(key => { + if (data[key] !== null && data[key].hasOwnProperty('_bsontype') && data[key]._bsontype === 'ObjectID') { // stringify id + data[key] = data[key].toString(); + } + else if (typeof data[key] === 'object' && data[key] !== null) { // deeper into recursion + data[key] = this.stringify(data[key]); + } + }); + return data; + } +} \ No newline at end of file diff --git a/src/routes/validate/material.ts b/src/routes/validate/material.ts new file mode 100644 index 0000000..969ac43 --- /dev/null +++ b/src/routes/validate/material.ts @@ -0,0 +1,116 @@ +import Joi from '@hapi/joi'; + +import IdValidate from './id'; + +export default class MaterialValidate { // validate input for material + private static material = { + name: Joi.string() + .max(128), + + supplier: Joi.string() + .max(128), + + group: Joi.string() + .max(128), + + mineral: Joi.number() + .integer() + .min(0) + .max(100), + + glass_fiber: Joi.number() + .integer() + .min(0) + .max(100), + + carbon_fiber: Joi.number() + .integer() + .min(0) + .max(100), + + numbers: Joi.array() + .items(Joi.object({ + color: Joi.string() + .max(128) + .required(), + number: Joi.string() + .max(128) + .allow('') + .required() + })) + }; + + static input (data, param) { // validate input, set param to 'new' to make all attributes required + if (param === 'new') { + return Joi.object({ + name: this.material.name.required(), + supplier: this.material.supplier.required(), + group: this.material.group.required(), + mineral: this.material.mineral.required(), + glass_fiber: this.material.glass_fiber.required(), + carbon_fiber: this.material.carbon_fiber.required(), + numbers: this.material.numbers.required() + }).validate(data); + } + else if (param === 'change') { + return Joi.object({ + name: this.material.name, + supplier: this.material.supplier, + group: this.material.group, + mineral: this.material.mineral, + glass_fiber: this.material.glass_fiber, + carbon_fiber: this.material.carbon_fiber, + numbers: this.material.numbers + }).validate(data); + } + else { + return{error: 'No parameter specified!', value: {}}; + } + } + + static output (data) { // validate output and strip unwanted properties, returns null if not valid + data = IdValidate.stringify(data); + data.group = data.group_id.name; + data.supplier = data.supplier_id.name; + const {value, error} = Joi.object({ + _id: IdValidate.get(), + name: this.material.name, + supplier: this.material.supplier, + group: this.material.group, + mineral: this.material.mineral, + glass_fiber: this.material.glass_fiber, + carbon_fiber: this.material.carbon_fiber, + numbers: this.material.numbers + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } + + static outputGroups (data) {// validate groups output and strip unwanted properties, returns null if not valid + const {value, error} = this.material.group.validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } + + static outputSuppliers (data) {// validate suppliers output and strip unwanted properties, returns null if not valid + const {value, error} = this.material.supplier.validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } + + static outputV() { // return output validator + return Joi.object({ + _id: IdValidate.get(), + name: this.material.name, + supplier: this.material.supplier, + group: this.material.group, + mineral: this.material.mineral, + glass_fiber: this.material.glass_fiber, + carbon_fiber: this.material.carbon_fiber, + numbers: this.material.numbers + }); + } + + static query (data) { + return Joi.object({ + status: Joi.string().valid('validated', 'new', 'all') + }).validate(data); + } +} \ No newline at end of file diff --git a/src/routes/validate/measurement.ts b/src/routes/validate/measurement.ts new file mode 100644 index 0000000..0af8fbd --- /dev/null +++ b/src/routes/validate/measurement.ts @@ -0,0 +1,56 @@ +import Joi from '@hapi/joi'; + +import IdValidate from './id'; + +export default class MeasurementValidate { + private static measurement = { + values: Joi.object() + .pattern(/.*/, Joi.alternatives() + .try( + Joi.string().max(128), + Joi.number(), + Joi.boolean(), + Joi.array() + ) + .allow(null) + ) + }; + + static input (data, param) { // validate input, set param to 'new' to make all attributes required + if (param === 'new') { + return Joi.object({ + sample_id: IdValidate.get().required(), + values: this.measurement.values.required(), + measurement_template: IdValidate.get().required() + }).validate(data); + } + else if (param === 'change') { + return Joi.object({ + values: this.measurement.values + }).validate(data); + } + else { + return{error: 'No parameter specified!', value: {}}; + } + } + + static output (data) { // validate output and strip unwanted properties, returns null if not valid + data = IdValidate.stringify(data); + const {value, error} = Joi.object({ + _id: IdValidate.get(), + sample_id: IdValidate.get(), + values: this.measurement.values, + measurement_template: IdValidate.get() + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } + + static outputV() { // return output validator + return Joi.object({ + _id: IdValidate.get(), + sample_id: IdValidate.get(), + values: this.measurement.values, + measurement_template: IdValidate.get() + }); + } +} \ No newline at end of file diff --git a/src/routes/validate/note_field.ts b/src/routes/validate/note_field.ts new file mode 100644 index 0000000..68856c9 --- /dev/null +++ b/src/routes/validate/note_field.ts @@ -0,0 +1,18 @@ +import Joi from '@hapi/joi'; + +export default class NoteFieldValidate { + private static note_field = { + name: Joi.string() + .max(128), + + qty: Joi.number() + }; + + static output (data) { // validate output and strip unwanted properties, returns null if not valid + const {value, error} = Joi.object({ + name: this.note_field.name, + qty: this.note_field.qty + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } +} \ No newline at end of file diff --git a/src/routes/validate/parameters.ts b/src/routes/validate/parameters.ts new file mode 100644 index 0000000..e6070b0 --- /dev/null +++ b/src/routes/validate/parameters.ts @@ -0,0 +1,48 @@ +import Joi from '@hapi/joi'; + +export default class ParametersValidate { + static input (data, parameters, param) { // data to validate, parameters from template, param: 'new', 'change', 'null'(null values are allowed) + let joiObject = {}; + parameters.forEach(parameter => { + if (parameter.range.hasOwnProperty('values')) { // append right validation method according to parameter + joiObject[parameter.name] = Joi.alternatives() + .try(Joi.string().max(128), Joi.number(), Joi.boolean()) + .valid(...parameter.range.values); + } + else if (parameter.range.hasOwnProperty('min') && parameter.range.hasOwnProperty('max')) { + joiObject[parameter.name] = Joi.number() + .min(parameter.range.min) + .max(parameter.range.max); + } + else if (parameter.range.hasOwnProperty('min')) { + joiObject[parameter.name] = Joi.number() + .min(parameter.range.min); + } + else if (parameter.range.hasOwnProperty('max')) { + joiObject[parameter.name] = Joi.number() + .max(parameter.range.max); + } + else if (parameter.range.hasOwnProperty('type')) { + switch (parameter.range.type) { + case 'array': + joiObject[parameter.name] = Joi.array(); + break; + default: + joiObject[parameter.name] = Joi.string().max(128); + break; + } + } + else { + joiObject[parameter.name] = Joi.alternatives() + .try(Joi.string().max(128), Joi.number(), Joi.boolean()); + } + if (param === 'new') { + joiObject[parameter.name] = joiObject[parameter.name].required() + } + else if (param === 'null') { + joiObject[parameter.name] = joiObject[parameter.name].allow(null) + } + }); + return Joi.object(joiObject).validate(data); + } +} \ No newline at end of file diff --git a/src/routes/validate/res400.ts b/src/routes/validate/res400.ts new file mode 100644 index 0000000..e4595c8 --- /dev/null +++ b/src/routes/validate/res400.ts @@ -0,0 +1,5 @@ +// respond with 400 and include error details from the joi validation + +export default function res400 (error, res) { + res.status(400).json({status: 'Invalid body format', details: error.details[0].message}); +} \ No newline at end of file diff --git a/src/routes/validate/root.ts b/src/routes/validate/root.ts new file mode 100644 index 0000000..3d05f9b --- /dev/null +++ b/src/routes/validate/root.ts @@ -0,0 +1,50 @@ +import Joi from '@hapi/joi'; +import IdValidate from './id'; + +export default class RootValidate { // validate input for root methods + private static changelog = { + timestamp: Joi.date() + .iso() + .min('1970-01-01T00:00:00.000Z'), + + page: Joi.number() + .integer() + .min(0) + .default(0), + + pagesize: Joi.number() + .integer() + .min(0) + .default(25), + + action: Joi.string(), + + collection: Joi.string(), + + conditions: Joi.object(), + + data: Joi.object() + }; + + static changelogParams (data) { + return Joi.object({ + timestamp: this.changelog.timestamp.required(), + page: this.changelog.page, + pagesize: this.changelog.pagesize + }).validate(data); + } + + static changelogOutput (data) { + data.date = data._id.getTimestamp(); + data.collection = data.collectionName; + data = IdValidate.stringify(data); + const {value, error} = Joi.object({ + date: this.changelog.timestamp, + action: this.changelog.action, + collection: this.changelog.collection, + conditions: this.changelog.conditions, + data: this.changelog.data, + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } +} \ No newline at end of file diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts new file mode 100644 index 0000000..3fb28d9 --- /dev/null +++ b/src/routes/validate/sample.ts @@ -0,0 +1,223 @@ +import Joi from '@hapi/joi'; + +import IdValidate from './id'; +import UserValidate from './user'; +import MaterialValidate from './material'; +import MeasurementValidate from './measurement'; + +export default class SampleValidate { + private static sample = { + number: Joi.string() + .max(128), + + color: Joi.string() + .max(128) + .allow(''), + + type: Joi.string() + .max(128), + + batch: Joi.string() + .max(128) + .allow(''), + + condition: Joi.object(), + + notes: Joi.object({ + comment: Joi.string() + .max(512) + .allow(''), + + sample_references: Joi.array() + .items(Joi.object({ + sample_id: IdValidate.get(), + + relation: Joi.string() + .max(128) + })), + + custom_fields: Joi.object() + .pattern(/.*/, Joi.alternatives() + .try( + Joi.string().max(128), + Joi.number(), + Joi.boolean(), + Joi.date() + ) + ) + }), + + added: Joi.date() + .iso() + .min('1970-01-01T00:00:00.000Z') + }; + + private static sortKeys = [ + '_id', + 'color', + 'number', + 'type', + 'batch', + 'added', + 'material.name', + 'material.supplier', + 'material.group', + 'material.mineral', + 'material.glass_fiber', + 'material.carbon_fiber', + 'material.number', + 'measurements.(?!spectrum)*' + ]; + + private static fieldKeys = [ + ...SampleValidate.sortKeys, + 'condition', + 'material_id', + 'material', + 'note_id', + 'user_id', + 'material._id', + 'material.numbers', + 'measurements.spectrum.dpt' + ]; + + static input (data, param) { // validate input, set param to 'new' to make all attributes required + if (param === 'new') { + return Joi.object({ + color: this.sample.color.required(), + type: this.sample.type.required(), + batch: this.sample.batch.required(), + condition: this.sample.condition.required(), + material_id: IdValidate.get().required(), + notes: this.sample.notes.required() + }).validate(data); + } + else if (param === 'change') { + return Joi.object({ + color: this.sample.color, + type: this.sample.type, + batch: this.sample.batch, + condition: this.sample.condition, + material_id: IdValidate.get(), + notes: this.sample.notes, + }).validate(data); + } + else if (param === 'new-admin') { + return Joi.object({ + number: this.sample.number, + color: this.sample.color.required(), + type: this.sample.type.required(), + batch: this.sample.batch.required(), + condition: this.sample.condition.required(), + material_id: IdValidate.get().required(), + notes: this.sample.notes.required() + }).validate(data); + } + else { + return{error: 'No parameter specified!', value: {}}; + } + } + + static output (data, param = 'refs+added', additionalParams = []) { // validate output and strip unwanted properties, returns null if not valid + if (param === 'refs+added') { + param = 'refs'; + data.added = data._id.getTimestamp(); + } + data = IdValidate.stringify(data); + let joiObject; + if (param === 'refs') { + joiObject = { + _id: IdValidate.get(), + number: this.sample.number, + color: this.sample.color, + type: this.sample.type, + batch: this.sample.batch, + condition: this.sample.condition, + material_id: IdValidate.get(), + material: MaterialValidate.outputV().append({number: Joi.string().max(128).allow('')}), + note_id: IdValidate.get().allow(null), + user_id: IdValidate.get(), + added: this.sample.added + }; + } + else if(param === 'details') { + joiObject = { + _id: IdValidate.get(), + number: this.sample.number, + color: this.sample.color, + type: this.sample.type, + batch: this.sample.batch, + condition: this.sample.condition, + material: MaterialValidate.outputV(), + measurements: Joi.array().items(MeasurementValidate.outputV()), + notes: this.sample.notes, + user: UserValidate.username() + } + } + else { + return null; + } + additionalParams.forEach(param => { + joiObject[param] = Joi.any(); + }); + const {value, error} = Joi.object(joiObject).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } + + static query (data) { + if (data.filters && data.filters.length) { + const filterValidation = Joi.array().items(Joi.string()).validate(data.filters); + if (filterValidation.error) return filterValidation; + try { + for (let i in data.filters) { + data.filters[i] = JSON.parse(data.filters[i]); + data.filters[i].values = data.filters[i].values.map(e => { // validate filter values + let validator; + let field = data.filters[i].field + if (/material\./.test(field)) { // select right validation model + validator = MaterialValidate.outputV().append({number: Joi.string().max(128).allow('')}); + field = field.replace('material.', ''); + } + else if (/measurements\./.test(field)) { + validator = Joi.object({ + value: Joi.alternatives() + .try( + Joi.number(), + Joi.string().max(128), + Joi.boolean(), + Joi.array() + ) + .allow(null) + }); + field = 'value'; + } + else { + validator = Joi.object(this.sample); + } + const {value, error} = validator.validate({[field]: e}); + console.log(value); + if (error) throw error; // reject invalid values // TODO: return exact error description, handle in frontend filters + return value[field]; + }); + } + } + catch { + return {error: {details: [{message: 'Invalid JSON string for filter parameter'}]}, value: null} + } + } + return Joi.object({ + status: Joi.string().valid('validated', 'new', 'all'), + 'from-id': IdValidate.get(), + 'to-page': Joi.number().integer(), + 'page-size': Joi.number().integer().min(1), + sort: Joi.string().pattern(new RegExp('^(' + this.sortKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')-(asc|desc)$', 'm')).default('_id-asc'), + csv: Joi.boolean().default(false), + fields: Joi.array().items(Joi.string().pattern(new RegExp('^(' + this.fieldKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')$', 'm'))).default(['_id','number','type','batch','material_id','color','condition','note_id','user_id','added']), + filters: Joi.array().items(Joi.object({ + mode: Joi.string().valid('eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'nin'), + field: Joi.string().pattern(new RegExp('^(' + this.fieldKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')$', 'm')), + values: Joi.array().items(Joi.alternatives().try(Joi.string().max(128), Joi.number(), Joi.boolean(), Joi.date().iso())).min(1) + })).default([]) + }).with('to-page', 'page-size').validate(data); + } +} \ No newline at end of file diff --git a/src/routes/validate/template.ts b/src/routes/validate/template.ts new file mode 100644 index 0000000..ae9426a --- /dev/null +++ b/src/routes/validate/template.ts @@ -0,0 +1,70 @@ +import Joi from '@hapi/joi'; +import IdValidate from './id'; + +// TODO: do not allow a . in the name +export default class TemplateValidate { + private static template = { + name: Joi.string() + .max(128), + + version: Joi.number() + .min(1), + + parameters: Joi.array() + .items( + Joi.object({ + name: Joi.string() + .max(128) + .invalid('condition_template') + .required(), + + range: Joi.object({ + values: Joi.array() + .min(1), + + min: Joi.number(), + + max: Joi.number(), + + type: Joi.string() + .valid('array') + }) + .oxor('values', 'min') + .oxor('values', 'max') + .oxor('type', 'values') + .oxor('type', 'min') + .oxor('type', 'max') + .required() + }) + ) + }; + + static input (data, param) { // validate input, set param to 'new' to make all attributes required + if (param === 'new') { + return Joi.object({ + name: this.template.name.required(), + parameters: this.template.parameters.required() + }).validate(data); + } + else if (param === 'change') { + return Joi.object({ + name: this.template.name, + parameters: this.template.parameters + }).validate(data); + } + else { + return{error: 'No parameter specified!', value: {}}; + } + } + + static output (data) { // validate output and strip unwanted properties, returns null if not valid + data = IdValidate.stringify(data); + const {value, error} = Joi.object({ + _id: IdValidate.get(), + name: this.template.name, + version: this.template.version, + parameters: this.template.parameters + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } +} \ No newline at end of file diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts new file mode 100644 index 0000000..9c0c7d1 --- /dev/null +++ b/src/routes/validate/user.ts @@ -0,0 +1,91 @@ +import Joi from '@hapi/joi'; +import globals from '../../globals'; + +import IdValidate from './id'; + +export default class UserValidate { // validate input for user + private static user = { + name: Joi.string() + .lowercase() + .pattern(new RegExp('^[a-z0-9-_.]+$')) + .max(128), + + email: Joi.string() + .email({minDomainSegments: 2}) + .lowercase() + .max(128), + + pass: Joi.string() + .pattern(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&'()*+,-.\/:;<=>?@[\]^_`{|}~])(?=\S+$)[a-zA-Z0-9!"#%&'()*+,\-.\/:;<=>?@[\]^_`{|}~]{8,}$/) + .max(128), + + level: Joi.string() + .valid(...globals.levels), + + location: Joi.string() + .alphanum() + .max(128), + + device_name: Joi.string() + .allow('') + .max(128), + }; + + private static specialUsernames = ['admin', 'user', 'key', 'new', 'passreset']; // names a user cannot take + + static input (data, param) { // validate input, set param to 'new' to make all attributes required + if (param === 'new') { + return Joi.object({ + name: this.user.name.required(), + email: this.user.email.required(), + pass: this.user.pass.required(), + level: this.user.level.required(), + location: this.user.location.required(), + device_name: this.user.device_name.required() + }).validate(data); + } + else if (param === 'change') { + return Joi.object({ + name: this.user.name, + email: this.user.email, + pass: this.user.pass, + location: this.user.location, + device_name: this.user.device_name + }).validate(data); + } + else if (param === 'changeadmin') { + return Joi.object({ + name: this.user.name, + email: this.user.email, + pass: this.user.pass, + level: this.user.level, + location: this.user.location, + device_name: this.user.device_name + }).validate(data); + } + else { + return{error: 'No parameter specified!', value: {}}; + } + } + + static output (data) { // validate output and strip unwanted properties, returns null if not valid + data = IdValidate.stringify(data); + const {value, error} = Joi.object({ + _id: IdValidate.get(), + name: this.user.name, + email: this.user.email, + level: this.user.level, + location: this.user.location, + device_name: this.user.device_name + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } + + static isSpecialName (name) { // true if name belongs to special names + return this.specialUsernames.indexOf(name) > -1; + } + + static username() { + return this.user.name; + } +} diff --git a/src/test/db.json b/src/test/db.json new file mode 100644 index 0000000..99ae417 --- /dev/null +++ b/src/test/db.json @@ -0,0 +1,673 @@ +{ + "collections": { + "samples": [ + { + "_id": {"$oid":"400000000000000000000001"}, + "number": "1", + "type": "granulate", + "color": "black", + "batch": "", + "condition": { + "material": "copper", + "weeks": 3, + "condition_template": {"$oid":"200000000000000000000001"} + }, + "material_id": {"$oid":"100000000000000000000004"}, + "note_id": null, + "user_id": {"$oid":"000000000000000000000002"}, + "status": 10, + "__v": 0 + }, + { + "_id": {"$oid":"400000000000000000000002"}, + "number": "21", + "type": "granulate", + "color": "natural", + "batch": "1560237365", + "condition": { + "material": "copper", + "weeks": 3, + "condition_template": {"$oid":"200000000000000000000001"} + }, + "material_id": {"$oid":"100000000000000000000001"}, + "note_id": {"$oid":"500000000000000000000001"}, + "user_id": {"$oid":"000000000000000000000002"}, + "status": 10, + "__v": 0 + }, + { + "_id": {"$oid":"400000000000000000000003"}, + "number": "33", + "type": "part", + "color": "black", + "batch": "1704-005", + "condition": { + "material": "copper", + "weeks": 3, + "condition_template": {"$oid":"200000000000000000000001"} + }, + "material_id": {"$oid":"100000000000000000000005"}, + "note_id": {"$oid":"500000000000000000000002"}, + "user_id": {"$oid":"000000000000000000000003"}, + "status": 0, + "__v": 0 + }, + { + "_id": {"$oid":"400000000000000000000004"}, + "number": "32", + "type": "granulate", + "color": "black", + "batch": "1653000308", + "condition": { + "p1": 44, + "condition_template": {"$oid":"200000000000000000000004"} + }, + "material_id": {"$oid":"100000000000000000000005"}, + "note_id": {"$oid":"500000000000000000000003"}, + "user_id": {"$oid":"000000000000000000000003"}, + "status": 0, + "__v": 0 + }, + { + "_id": {"$oid":"400000000000000000000005"}, + "number": "Rng33", + "type": "granulate", + "color": "black", + "batch": "1653000308", + "condition": { + "condition_template": {"$oid":"200000000000000000000003"} + }, + "material_id": {"$oid":"100000000000000000000005"}, + "note_id": null, + "user_id": {"$oid":"000000000000000000000003"}, + "status": -1, + "__v": 0 + }, + { + "_id": {"$oid":"400000000000000000000006"}, + "number": "Rng36", + "type": "granulate", + "color": "black", + "batch": "", + "condition": {}, + "material_id": {"$oid":"100000000000000000000004"}, + "note_id": null, + "user_id": {"$oid":"000000000000000000000002"}, + "status": 0, + "__v": 0 + } + ], + "notes": [ + { + "_id": {"$oid":"500000000000000000000001"}, + "comment": "Stoff gesperrt", + "sample_references": [], + "__v": 0 + }, + { + "_id": {"$oid":"500000000000000000000002"}, + "comment": "", + "sample_references": [{ + "sample_id": {"$oid":"400000000000000000000004"}, + "relation": "granulate to sample" + }], + "custom_fields": { + "not allowed for new applications": true + }, + "__v": 0 + }, + { + "_id": {"$oid":"500000000000000000000003"}, + "comment": "", + "sample_references": [{ + "sample_id": {"$oid":"400000000000000000000003"}, + "relation": "part to sample" + }], + "custom_fields": { + "not allowed for new applications": true, + "another_field": "is there" + }, + "__v": 0 + } + ], + "note_fields": [ + { + "_id": {"$oid":"600000000000000000000001"}, + "name": "not allowed for new applications", + "qty": 2, + "__v": 0 + }, + { + "_id": {"$oid":"600000000000000000000002"}, + "name": "another_field", + "qty": 1, + "__v": 0 + } + ], + "materials": [ + { + "_id": {"$oid":"100000000000000000000001"}, + "name": "Stanyl TW 200 F8", + "supplier_id": {"$oid":"110000000000000000000001"}, + "group_id": {"$oid":"900000000000000000000001"}, + "mineral": 0, + "glass_fiber": 40, + "carbon_fiber": 0, + "numbers": [ + { + "color": "black", + "number": "5514263423" + }, + { + "color": "natural", + "number": "5514263422" + } + ], + "status": 10, + "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000002"}, + "name": "Ultramid T KR 4355 G7", + "supplier_id": {"$oid":"110000000000000000000002"}, + "group_id": {"$oid":"900000000000000000000002"}, + "mineral": 0, + "glass_fiber": 35, + "carbon_fiber": 0, + "numbers": [ + { + "color": "black", + "number": "5514212901" + }, + { + "color": "signalviolet", + "number": "5514612901" + } + ], + "status": 10, + "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000003"}, + "name": "PA GF 50 black (2706)", + "supplier_id": {"$oid":"110000000000000000000003"}, + "group_id": {"$oid":"900000000000000000000003"}, + "mineral": 0, + "glass_fiber": 0, + "carbon_fiber": 0, + "numbers": [ + ], + "status": 10, + "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000004"}, + "name": "Schulamid 66 GF 25 H", + "supplier_id": {"$oid":"110000000000000000000004"}, + "group_id": {"$oid":"900000000000000000000004"}, + "mineral": 0, + "glass_fiber": 25, + "carbon_fiber": 0, + "numbers": [ + { + "color": "black", + "number": "5513933405" + } + ], + "status": 10, + "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000005"}, + "name": "Amodel A 1133 HS", + "supplier_id": {"$oid":"110000000000000000000005"}, + "group_id": {"$oid":"900000000000000000000005"}, + "mineral": 0, + "glass_fiber": 33, + "carbon_fiber": 0, + "numbers": [ + { + "color": "black", + "number": "5514262406" + } + ], + "status": 10, + "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000006"}, + "name": "PK-HM natural (4773)", + "supplier_id": {"$oid":"110000000000000000000003"}, + "group_id": {"$oid":"900000000000000000000006"}, + "mineral": 0, + "glass_fiber": 0, + "carbon_fiber": 0, + "numbers": [ + { + "color": "natural", + "number": "10000000" + } + ], + "status": -1, + "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000007"}, + "name": "Ultramid A4H", + "supplier_id": {"$oid":"110000000000000000000002"}, + "group_id": {"$oid":"900000000000000000000004"}, + "mineral": 0, + "glass_fiber": 0, + "carbon_fiber": 0, + "numbers": [ + { + "color": "black", + "number": "" + } + ], + "status": 0, + "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000008"}, + "name": "Latamid 66 H 2 G 30", + "supplier_id": {"$oid":"110000000000000000000006"}, + "group_id": {"$oid":"900000000000000000000004"}, + "mineral": 0, + "glass_fiber": 30, + "carbon_fiber": 0, + "numbers": [ + { + "color": "blue", + "number": "5513943509" + } + ], + "status": -1, + "__v": 0 + } + ], + "material_groups": [ + { + "_id": {"$oid":"900000000000000000000001"}, + "name": "PA46", + "__v": 0 + }, + { + "_id": {"$oid":"900000000000000000000002"}, + "name": "PA6/6T", + "__v": 0 + }, + { + "_id": {"$oid":"900000000000000000000003"}, + "name": "PA66+PA6I/6T", + "__v": 0 + }, + { + "_id": {"$oid":"900000000000000000000004"}, + "name": "PA66", + "__v": 0 + }, + { + "_id": {"$oid":"900000000000000000000005"}, + "name": "PPA", + "__v": 0 + }, + { + "_id": {"$oid":"900000000000000000000006"}, + "name": "PK", + "__v": 0 + } + ], + "material_suppliers": [ + { + "_id": {"$oid":"110000000000000000000001"}, + "name": "DSM", + "__v": 0 + }, + { + "_id": {"$oid":"110000000000000000000002"}, + "name": "BASF", + "__v": 0 + }, + { + "_id": {"$oid":"110000000000000000000003"}, + "name": "Akro-Plastic", + "__v": 0 + }, + { + "_id": {"$oid":"110000000000000000000004"}, + "name": "Schulmann", + "__v": 0 + }, + { + "_id": {"$oid":"110000000000000000000005"}, + "name": "Solvay", + "__v": 0 + }, + { + "_id": {"$oid":"110000000000000000000006"}, + "name": "LATI", + "__v": 0 + } + ], + "measurements": [ + { + "_id": {"$oid":"800000000000000000000001"}, + "sample_id": {"$oid":"400000000000000000000001"}, + "values": { + "dpt": [ + [3997.12558,98.00555], + [3995.08519,98.03253], + [3993.04480,98.02657] + ] + }, + "status": 10, + "measurement_template": {"$oid":"300000000000000000000001"}, + "__v": 0 + }, + { + "_id": {"$oid":"800000000000000000000002"}, + "sample_id": {"$oid":"400000000000000000000002"}, + "values": { + "weight %": 0.5, + "standard deviation": 0.2 + }, + "status": 10, + "measurement_template": {"$oid":"300000000000000000000002"}, + "__v": 0 + }, + { + "_id": {"$oid":"800000000000000000000003"}, + "sample_id": {"$oid":"400000000000000000000003"}, + "values": { + "val1": 1 + }, + "status": 0, + "measurement_template": {"$oid":"300000000000000000000003"}, + "__v": 0 + }, + { + "_id": {"$oid":"800000000000000000000004"}, + "sample_id": {"$oid":"400000000000000000000003"}, + "values": { + "val1": 1 + }, + "status": -1, + "measurement_template": {"$oid":"300000000000000000000003"}, + "__v": 0 + }, + { + "_id": {"$oid":"800000000000000000000005"}, + "sample_id": {"$oid":"400000000000000000000002"}, + "values": { + "weight %": 0.5, + "standard deviation":null + }, + "status": 10, + "measurement_template": {"$oid":"300000000000000000000002"}, + "__v": 0 + }, + { + "_id": {"$oid":"800000000000000000000006"}, + "sample_id": {"$oid":"400000000000000000000006"}, + "values": { + "weight %": 0.6, + "standard deviation":null + }, + "status": 0, + "measurement_template": {"$oid":"300000000000000000000002"}, + "__v": 0 + }, + { + "_id": {"$oid":"800000000000000000000007"}, + "sample_id": {"$oid":"400000000000000000000001"}, + "values": { + "dpt": [ + [3996.12558,98.00555], + [3995.08519,98.03253], + [3993.04480,98.02657] + ] + }, + "status": 10, + "measurement_template": {"$oid":"300000000000000000000001"}, + "__v": 0 + } + ], + "condition_templates": [ + { + "_id": {"$oid":"200000000000000000000001"}, + "first_id": {"$oid":"200000000000000000000001"}, + "name": "heat treatment", + "version": 1, + "parameters": [ + { + "name": "material", + "range": { + "values": [ + "copper", + "hot air" + ] + } + }, + { + "name": "weeks", + "range": { + "min": 1, + "max": 10 + } + } + ], + "__v": 0 + }, + { + "_id": {"$oid":"200000000000000000000003"}, + "first_id": {"$oid":"200000000000000000000003"}, + "name": "raw material", + "version": 1, + "parameters": [ + ], + "__v": 0 + }, + { + "_id": {"$oid":"200000000000000000000004"}, + "first_id": {"$oid":"200000000000000000000004"}, + "name": "old condition", + "version": 1, + "parameters": [ + { + "name": "p1", + "range": {} + } + ], + "__v": 0 + }, + { + "_id": {"$oid":"200000000000000000000005"}, + "first_id": {"$oid":"200000000000000000000004"}, + "name": "new condition", + "version": 2, + "parameters": [ + { + "name": "p11", + "range": {} + } + ], + "__v": 0 + } + ], + "measurement_templates": [ + { + "_id": {"$oid":"300000000000000000000001"}, + "first_id": {"$oid":"300000000000000000000001"}, + "name": "spectrum", + "version": 1, + "parameters": [ + { + "name": "dpt", + "range": { + "type": "array" + } + } + ], + "__v": 0 + }, + { + "_id": {"$oid":"300000000000000000000002"}, + "first_id": {"$oid":"300000000000000000000002"}, + "name": "kf", + "version": 1, + "parameters": [ + { + "name": "weight %", + "range": { + "min": 0, + "max": 1.5 + } + }, + { + "name": "standard deviation", + "range": { + "min": 0, + "max": 0.5 + } + } + ], + "__v": 0 + }, + { + "_id": {"$oid":"300000000000000000000003"}, + "first_id": {"$oid":"300000000000000000000003"}, + "name": "mt 3", + "version": 1, + "parameters": [ + { + "name": "val1", + "range": { + "values": [1,2,3] + } + } + ], + "__v": 0 + }, + { + "_id": {"$oid":"300000000000000000000004"}, + "first_id": {"$oid":"300000000000000000000003"}, + "name": "mt 31", + "version": 2, + "parameters": [ + { + "name": "val2", + "range": { + "values": [1,2,3,4] + } + } + ], + "__v": 0 + } + ], + "users": [ + { + "_id": {"$oid":"000000000000000000000001"}, + "email": "user@bosch.com", + "name": "user", + "pass": "$2a$10$di26XKF63OG0V00PL1kSK.ceCcTxDExBMOg.jkHiCnXcY7cN7DlPi", + "level": "read", + "location": "Rng", + "device_name": "Alpha I", + "key": "000000000000000000001001", + "__v": 0 + }, + { + "_id": {"$oid":"000000000000000000000002"}, + "email": "jane.doe@bosch.com", + "name": "janedoe", + "pass": "$2a$10$di26XKF63OG0V00PL1kSK.ceCcTxDExBMOg.jkHiCnXcY7cN7DlPi", + "level": "write", + "location": "Rng", + "device_name": "Alpha I", + "key": "000000000000000000001002", + "__v": 0 + }, + { + "_id": {"$oid":"000000000000000000000003"}, + "email": "a.d.m.i.n@bosch.com", + "name": "admin", + "pass": "$2a$10$i872o3qR5V3JnbDArD8Z.eDo.BNPDBaR7dUX9KSEtl9pUjLyucy2K", + "level": "admin", + "location": "Rng", + "device_name": "", + "key": "000000000000000000001003", + "__v": "0" + }, + { + "_id": {"$oid":"000000000000000000000004"}, + "email": "johnny.doe@bosch.com", + "name": "johnnydoe", + "pass": "$2a$10$di26XKF63OG0V00PL1kSK.ceCcTxDExBMOg.jkHiCnXcY7cN7DlPi", + "level": "write", + "location": "Fe", + "device_name": "Alpha I", + "key": "000000000000000000001004", + "__v": 0 + } + ], + "changelogs": [ + { + "_id" : {"$oid": "120000010000000000000000"}, + "action" : "PUT /sample/400000000000000000000001", + "collectionName" : "samples", + "conditions" : { + "_id" : {"$oid": "400000000000000000000001"} + }, + "data" : { + "type" : "part", + "status" : 0 + }, + "user_id" : {"$oid": "000000000000000000000003"}, + "__v" : 0 + }, + { + "_id" : {"$oid": "120000020000000000000000"}, + "action" : "PUT /sample/400000000000000000000001", + "collectionName" : "samples", + "conditions" : { + "_id" : {"$oid": "400000000000000000000001"} + }, + "data" : { + "type" : "part", + "status" : 0 + }, + "user_id" : {"$oid": "000000000000000000000003"}, + "__v" : 0 + }, + { + "_id" : {"$oid": "120000030000000000000000"}, + "action" : "PUT /sample/400000000000000000000001", + "collectionName" : "samples", + "conditions" : { + "_id" : {"$oid": "400000000000000000000001"} + }, + "data" : { + "type" : "part", + "status" : 0 + }, + "user_id" : {"$oid": "000000000000000000000003"}, + "__v" : 0 + }, + { + "_id" : {"$oid": "120000040000000000000000"}, + "action" : "PUT /sample/400000000000000000000001", + "collectionName" : "samples", + "conditions" : { + "_id" : {"$oid": "400000000000000000000001"} + }, + "data" : { + "type" : "part", + "status" : 0 + }, + "user_id" : {"$oid": "000000000000000000000003"}, + "__v" : 0 + } + ] + } +} \ No newline at end of file diff --git a/src/test/helper.ts b/src/test/helper.ts new file mode 100644 index 0000000..44085f7 --- /dev/null +++ b/src/test/helper.ts @@ -0,0 +1,135 @@ +import supertest from 'supertest'; +import should from 'should/as-function'; +import _ from 'lodash'; +import db from '../db'; +import ChangelogModel from '../models/changelog'; +import IdValidate from '../routes/validate/id'; + + +export default class TestHelper { + public static auth = { // test user credentials + admin: {pass: 'Abc123!#', key: '000000000000000000001003', id: '000000000000000000000003'}, + janedoe: {pass: 'Xyz890*)', key: '000000000000000000001002', id: '000000000000000000000002'}, + user: {pass: 'Xyz890*)', key: '000000000000000000001001', id: '000000000000000000000001'}, + johnnydoe: {pass: 'Xyz890*)', key: '000000000000000000001004', id: '000000000000000000000004'} + } + + public static res = { // default responses + 400: {status: 'Bad request'}, + 401: {status: 'Unauthorized'}, + 403: {status: 'Forbidden'}, + 404: {status: 'Not found'}, + 500: {status: 'Internal server error'} + } + + static before (done) { + process.env.port = '2999'; + process.env.NODE_ENV = 'test'; + db.connect('test', done); + } + + static beforeEach (server, done) { + delete require.cache[require.resolve('../index')]; // prevent loading from cache + server = require('../index'); + db.drop(err => { // reset database + if (err) return done(err); + db.loadJson(require('./db.json'), done); + }); + return server + } + + static request (server, done, options) { // options in form: {method, url, contentType, auth: {key/basic: 'name' or 'key'/{name, pass}}, httpStatus, req, res, default (set to false if you want to dismiss default .end handling)} + let st = supertest(server); + if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('key')) { // resolve API key + options.url += '?key=' + (this.auth.hasOwnProperty(options.auth.key)? this.auth[options.auth.key].key : options.auth.key); + } + switch (options.method) { // http method + case 'get': + st = st.get(options.url) + break; + case 'post': + st = st.post(options.url) + break; + case 'put': + st = st.put(options.url) + break; + case 'delete': + st = st.delete(options.url) + break; + } + if (options.hasOwnProperty('reqType')) { // request body + st = st.type(options.reqType); + } + if (options.hasOwnProperty('req')) { // request body + st = st.send(options.req); + } + if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('basic')) { // resolve basic auth + if (this.auth.hasOwnProperty(options.auth.basic)) { + st = st.auth(options.auth.basic, this.auth[options.auth.basic].pass) + } + else { + st = st.auth(options.auth.basic.name, options.auth.basic.pass) + } + } + if (options.hasOwnProperty('contentType')) { + st = st.expect('Content-type', options.contentType).expect(options.httpStatus); + } + else { + st = st.expect('Content-type', /json/).expect(options.httpStatus); + } + if (options.hasOwnProperty('res')) { // evaluate result + return st.end((err, res) => { + if (err) return done (err); + should(res.body).be.eql(options.res); + done(); + }); + } + else if (this.res.hasOwnProperty(options.httpStatus) && options.default !== false) { // evaluate default results + return st.end((err, res) => { + if (err) return done (err); + should(res.body).be.eql(this.res[options.httpStatus]); + done(); + }); + } + else if (options.hasOwnProperty('log')) { // check changelog, takes log: {collection, skip, data/(dataAdd, dataIgn)} + return st.end(err => { + if (err) return done (err); + ChangelogModel.findOne({}).sort({_id: -1}).skip(options.log.skip? options.log.skip : 0).lean().exec((err, data) => { // latest entry + if (err) return done(err); + should(data).have.only.keys('_id', 'action', 'collectionName', 'conditions', 'data', 'user_id', '__v'); + should(data).have.property('action', options.method.toUpperCase() + ' ' + options.url); + should(data).have.property('collectionName', options.log.collection); + if (options.log.hasOwnProperty('data')) { + should(data).have.property('data', options.log.data); + } + else { + const ignore = ['_id', '__v']; + if (options.log.hasOwnProperty('dataIgn')) { + ignore.push(...options.log.dataIgn); + } + let tmp = options.req ? options.req : {}; + if (options.log.hasOwnProperty('dataAdd')) { + _.assign(tmp, options.log.dataAdd) + } + should(IdValidate.stringify(_.omit(data.data, ignore))).be.eql(_.omit(tmp, ignore)); + } + if (data.user_id) { + should(data.user_id.toString()).be.eql(this.auth[options.auth.basic].id); + } + done(); + }); + }); + } + else { // return object to do .end() manually + return st; + } + } + + static afterEach (server, done) { + server.close(done); + } + + static after(done) { + db.disconnect(done); + } +} \ No newline at end of file diff --git a/src/test/loadDev.ts b/src/test/loadDev.ts new file mode 100644 index 0000000..15a6868 --- /dev/null +++ b/src/test/loadDev.ts @@ -0,0 +1,14 @@ +import db from '../db'; + +// script to load test db into dev db for a clean start + +db.connect('dev', () => { + console.info('dropping data...'); + db.drop(() => { // reset database + console.info('loading data...'); + db.loadJson(require('./db.json'), () => { + console.info('done'); + process.exit(0); + }); + }); +}); diff --git a/static/img/bosch-logo.svg b/static/img/bosch-logo.svg new file mode 100644 index 0000000..fae963f --- /dev/null +++ b/static/img/bosch-logo.svg @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/styles/swagger.css b/static/styles/swagger.css new file mode 100644 index 0000000..9760ed4 --- /dev/null +++ b/static/styles/swagger.css @@ -0,0 +1,323 @@ +/*Bosch styling for swagger*/ + +/*GET: dark blue*/ +/*POST: dark green*/ +/*PUT: turquoise*/ +/*DELETE: fuchsia*/ + +:root { + --red: #ea0016; + --dark-blue: #005691; + --dark-blue-w75: #bfd5e3; + --dark-green: #006249; + --dark-green-w75: #bfd8d1; + --turquoise: #00a8b0; + --turquoise-w75: #bfe9eb; + --fuchsia: #b90276; + --fuchsia-w75: #edc0dd; + --light-grey: #bfc0c2; + --light-grey-w75: #efeff0; + --light-green: #78be20; +} + +body { + background: #fff; +} + +body:before { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 16px; + content: ''; + background-repeat: no-repeat; + background-size: cover; + background-image: url(data:image/svg+xml;base64,PHN2ZwogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICB4bWw6c3BhY2U9InByZXNlcnZlIgogIGhlaWdodD0iMzAwIgogIHdpZHRoPSI3MjAiCiAgdmVyc2lvbj0iMS4xIgogIHk9IjAiCiAgeD0iMCIKICB2aWV3Qm94PSIwIDAgNzIwIDMwMCI+CiAgPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KICAgIC5zdDAgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF8xXyIpOwogICAgfQogICAgLnN0MSB7CiAgICAgIGZpbGw6IHVybCgiI1NWR0lEXzJfIik7CiAgICB9CiAgICAuc3QyIHsKICAgICAgZmlsbDogdXJsKCIjU1ZHSURfM18iKTsKICAgIH0KICAgIC5zdDMgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF80XyIpOwogICAgfQogICAgLnN0NCB7CiAgICAgIGZpbGw6IHVybCgiI1NWR0lEXzVfIik7CiAgICB9CiAgICAuc3Q1IHsKICAgICAgZmlsbDogI0FGMjAyNDsKICAgIH0KICAgIC5zdDYgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF82XyIpOwogICAgfQogICAgLnN0NyB7CiAgICAgIGZpbGw6ICM5NDFCMUU7CiAgICB9CiAgICAuc3Q4IHsKICAgICAgZmlsbDogI0IxMjczOTsKICAgIH0KICAgIC5zdDkgewogICAgICBmaWxsOiAjOTUyNDMyOwogICAgfQogICAgLnN0MTAgewogICAgICBmaWxsOiAjRDQyMDI3OwogICAgfQogICAgLnN0MTEgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF83XyIpOwogICAgfQogICAgLnN0MTIgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF84XyIpOwogICAgfQogICAgLnN0MTMgewogICAgICBmaWxsOiAjMUM5QTQ4OwogICAgfQogICAgLnN0MTQgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF85XyIpOwogICAgfQogICAgLnN0MTUgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF8xMF8iKTsKICAgIH0KICAgIC5zdDE2IHsKICAgICAgZmlsbDogIzJBMzg4NjsKICAgIH0KICAgIC5zdDE3IHsKICAgICAgZmlsbDogdXJsKCIjU1ZHSURfMTFfIik7CiAgICB9CiAgICAuc3QxOCB7CiAgICAgIGZpbGw6IHVybCgiI1NWR0lEXzEyXyIpOwogICAgfQogICAgLnN0MTkgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF8xM18iKTsKICAgIH0KICAgIC5zdDIwIHsKICAgICAgZmlsbDogdXJsKCIjU1ZHSURfMTRfIik7CiAgICB9CiAgPC9zdHlsZT4KICA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMS41NSwtMy4zKSI+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzFfIiB5Mj0iLTMyLjY2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIHkxPSItMzIuNjYzIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9Ijg0Mi4wOCIgeDE9IjExOC45OCI+PHN0b3Agc3RvcC1jb2xvcj0iIzk1MjMzMSIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzkyMUMxRCIgb2Zmc2V0PSIuMDM2MDk0Ii8+PHN0b3Agc3RvcC1jb2xvcj0iI0IwMjczOSIgb2Zmc2V0PSIuMDg0NjQ5Ii8+PHN0b3Agc3RvcC1jb2xvcj0iI0FEMUYyNCIgb2Zmc2V0PSIuMTIzNyIvPjxzdG9wIHN0b3AtY29sb3I9IiNDNzIwMjYiIG9mZnNldD0iLjE1MDkiLz48c3RvcCBzdG9wLWNvbG9yPSIjRDQyMDI3IiBvZmZzZXQ9Ii4xNjk3Ii8+PHN0b3Agc3RvcC1jb2xvcj0iI0NDMjQzMSIgb2Zmc2V0PSIuMTc1OCIvPjxzdG9wIHN0b3AtY29sb3I9IiNCNzJCNEMiIG9mZnNldD0iLjE4ODgiLz48c3RvcCBzdG9wLWNvbG9yPSIjOTUzMzcxIiBvZmZzZXQ9Ii4yMDc0Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzg4MzU3RiIgb2Zmc2V0PSIuMjE0MiIvPjxzdG9wIHN0b3AtY29sb3I9IiM4NTM2ODEiIG9mZnNldD0iLjI0MzYiLz48c3RvcCBzdG9wLWNvbG9yPSIjNkYzNjhCIiBvZmZzZXQ9Ii4yNjM4Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzM5NDI4RiIgb2Zmc2V0PSIuMjkxMSIvPjxzdG9wIHN0b3AtY29sb3I9IiMyMzNEN0QiIG9mZnNldD0iLjMyNDIiLz48c3RvcCBzdG9wLWNvbG9yPSIjMzIyQzZGIiBvZmZzZXQ9Ii40MTgxIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzJBMzg4NSIgb2Zmc2V0PSIuNDk0Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzFENjJBMSIgb2Zmc2V0PSIuNTU4MSIvPjxzdG9wIHN0b3AtY29sb3I9IiMyNzZDQTUiIG9mZnNldD0iLjU3MDIiLz48c3RvcCBzdG9wLWNvbG9yPSIjNDM4RUIzIiBvZmZzZXQ9Ii42MTAzIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzU1QTVCQyIgb2Zmc2V0PSIuNjM5OSIvPjxzdG9wIHN0b3AtY29sb3I9IiM1Q0FGQkYiIG9mZnNldD0iLjY1NTYiLz48c3RvcCBzdG9wLWNvbG9yPSIjNTZBQkJEIiBvZmZzZXQ9Ii42Nzc3Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzQzOUZCOCIgb2Zmc2V0PSIuNzA1OCIvPjxzdG9wIHN0b3AtY29sb3I9IiMxODhFQUYiIG9mZnNldD0iLjczNzIiLz48c3RvcCBzdG9wLWNvbG9yPSIjMDM4QkFFIiBvZmZzZXQ9Ii43NDI2Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzA2OTI5MiIgb2Zmc2V0PSIuNzg5OCIvPjxzdG9wIHN0b3AtY29sb3I9IiMwNUExNEIiIG9mZnNldD0iLjg4NzUiLz48c3RvcCBzdG9wLWNvbG9yPSIjMDM5MjdFIiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50PjxyZWN0IHdpZHRoPSI3MjMuMSIgeT0iMCIgeD0iMCIgaGVpZ2h0PSIzMDYuNCIgY2xhc3M9InN0MCIgZmlsbD0idXJsKCNTVkdJRF8xXykiLz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMl8iIHkyPSItMTA5LjI2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9Ii0xMDkuMjYiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgLTExOC45OCAxMjAuNTQpIiB4Mj0iMjM1Ljk4IiB4MT0iMzI1LjA4Ij48c3RvcCBzdG9wLWNvbG9yPSIjODkzNjgwIiBvZmZzZXQ9IjAiLz48c3RvcCBzdG9wLWNvbG9yPSIjODkzNjgwIiBvZmZzZXQ9Ii4zMzU0Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzhEMzE2RCIgb2Zmc2V0PSIuNTAyNSIvPjxzdG9wIHN0b3AtY29sb3I9IiM5MDI5NEQiIG9mZnNldD0iLjgzOTgiLz48c3RvcCBzdG9wLWNvbG9yPSIjOTAyNTQxIiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50Pjxwb2x5Z29uIHBvaW50cz0iMTc1LjEgMTUzLjIgMTE3IDMwNi40IDIwNi4xIDMwNi40IiBmaWxsPSJ1cmwoI1NWR0lEXzJfKSIgY2xhc3M9InN0MSIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF8zXyIgeTI9Ii04Mi4yODQiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTIwLjI0IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjQ0Ni41NSIgeDE9IjQ3OC45MyI+PHN0b3Agc3RvcC1jb2xvcj0iIzMyMkM2RiIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzMyMkM2RiIgb2Zmc2V0PSIuMjQyNyIvPjxzdG9wIHN0b3AtY29sb3I9IiMzMDJGNzIiIG9mZnNldD0iLjQ1OTkiLz48c3RvcCBzdG9wLWNvbG9yPSIjMkEzQTdFIiBvZmZzZXQ9Ii43MTU1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzE1NEE5MyIgb2Zmc2V0PSIuOTg5NiIvPjxzdG9wIHN0b3AtY29sb3I9IiMxMzRCOTQiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PHBvbHlnb24gcG9pbnRzPSIyODguNCAxNTMuMiAzMTAuNyAzMDYuNCAzNTguMSAzMDYuNCAzNTguMSAwIDMxMi45IDAiIGZpbGw9InVybCgjU1ZHSURfM18pIiBjbGFzcz0ic3QyIi8+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzRfIiB5Mj0iLTMyLjY2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIHkxPSItMzIuNjYzIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjM3Mi44OCIgeDE9IjI5NC4wOCI+PHN0b3Agc3RvcC1jb2xvcj0iIzZGMzc4RCIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzNBNDI5MSIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cG9seWdvbiBwb2ludHM9IjE3NS4xIDE1My4yIDIwNi4xIDMwNi40IDI1My45IDE1My4yIDIwOS40IDAgMjA5LjQgMCIgZmlsbD0idXJsKCNTVkdJRF80XykiIGNsYXNzPSJzdDMiLz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfNV8iIHkyPSItMzIuNjYzIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9Ii0zMi42NjMiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgLTExOC45OCAxMjAuNTQpIiB4Mj0iMzI1LjA4IiB4MT0iNDMxLjg4Ij48c3RvcCBzdG9wLWNvbG9yPSIjMjMzRDdEIiBvZmZzZXQ9IjAiLz48c3RvcCBzdG9wLWNvbG9yPSIjMjkzRDdEIiBvZmZzZXQ9Ii4yNDk1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzNBM0M4MCIgb2Zmc2V0PSIuNTQ0NiIvPjxzdG9wIHN0b3AtY29sb3I9IiM1MTNCODQiIG9mZnNldD0iLjg2MTYiLz48c3RvcCBzdG9wLWNvbG9yPSIjNUQzQTg2IiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50Pjxwb2x5Z29uIHBvaW50cz0iMjUzLjkgMTUzLjIgMjA2LjEgMzA2LjQgMzEwLjcgMzA2LjQgMjg4LjQgMTUzLjIgMzEyLjkgMCAyMDkuNCAwIiBmaWxsPSJ1cmwoI1NWR0lEXzVfKSIgY2xhc3M9InN0NCIvPjxwb2x5Z29uIHBvaW50cz0iMTE2LjEgMCA1NS43IDAgNTUuNyA5NC44IDg5LjkgMTUzLjIgNTUuNyAyMTEuNiA1NS43IDMwNi40IDExNyAzMDYuNCA5NS4yIDE1My4yIiBmaWxsPSIjYWYyMDI0IiBjbGFzcz0ic3Q1Ii8+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzZfIiB5Mj0iNDMuOTM3IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9IjQzLjkzNyIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAtMTE4Ljk4IDEyMC41NCkiIHgyPSIyMzIuNjciIHgxPSIzMjkuMTEiPjxzdG9wIHN0b3AtY29sb3I9IiM4OTM2ODAiIG9mZnNldD0iMCIvPjxzdG9wIHN0b3AtY29sb3I9IiM4OTM2ODAiIG9mZnNldD0iLjMzNTQiLz48c3RvcCBzdG9wLWNvbG9yPSIjOEQzMTZEIiBvZmZzZXQ9Ii41MDI1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzkwMjk0RCIgb2Zmc2V0PSIuODM5OCIvPjxzdG9wIHN0b3AtY29sb3I9IiM5MDI1NDEiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PHBvbHlnb24gcG9pbnRzPSIxNzUuMSAxNTMuMiAyMDkuNCAwIDExNi4xIDAiIGZpbGw9InVybCgjU1ZHSURfNl8pIiBjbGFzcz0ic3Q2Ii8+PHBvbHlnb24gcG9pbnRzPSI1NS43IDk0LjggNTUuNyAwIDAgMCIgZmlsbD0iIzk0MWIxZSIgY2xhc3M9InN0NyIvPjxwb2x5Z29uIHBvaW50cz0iNTUuNyAyMTEuNiA4OS45IDE1My4yIDU1LjcgOTQuOCIgZmlsbD0iI2IxMjczOSIgY2xhc3M9InN0OCIvPjxwb2x5Z29uIHBvaW50cz0iNTUuNyAyMTEuNiAwIDMwNi40IDU1LjcgMzA2LjQiIGZpbGw9IiM5NDFiMWUiIGNsYXNzPSJzdDciLz48cG9seWdvbiBwb2ludHM9IjU1LjcgOTQuOCAwIDAgMCAzMDYuNCA1NS43IDIxMS42IiBmaWxsPSIjOTUyNDMyIiBjbGFzcz0ic3Q5Ii8+PHBvbHlnb24gcG9pbnRzPSIxMTYuMSAwIDk1LjIgMTUzLjIgMTE3IDMwNi40IDE3NS4xIDE1My4yIiBmaWxsPSIjZDQyMDI3IiBjbGFzcz0ic3QxMCIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF83XyIgeTI9Ii0xODYuMDYiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTIwLjQ0IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9Ijc0OC45NiIgeDE9Ijc0OC45NiI+PHN0b3Agc3RvcC1jb2xvcj0iIzk0QkU1NSIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzkzQkQ1OCIgb2Zmc2V0PSIuMDQ0MzQwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzhCQkM2QSIgb2Zmc2V0PSIuMzg5MSIvPjxzdG9wIHN0b3AtY29sb3I9IiM4NkJDNzUiIG9mZnNldD0iLjcxNDkiLz48c3RvcCBzdG9wLWNvbG9yPSIjODRCQzc5IiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50PjxwYXRoCiAgICAgIGQ9Im02NDEuNiAyNTkuNmMxLjctMjUuNCAxMC01NC42IDE4LjgtODUuNiAxLjQtNSAyLjgtMTAgNC4yLTE1LjEtMS40LTUuNS0yLjgtMTAuOS00LjItMTYuMi04LjgtMzMuMy0xNy02NC43LTE4LjgtOTItMS40LTIxLjIgMS40LTM3IDguOS01MC42aC00NS45Yy03LjUgMTguMy0xMC4zIDI5LjEtOC45IDUwLjMgMS43IDI3LjMgMTAgNTguNyAxOC44IDkyIDEzIDQ5LjMgMjggMTA2LjIgMjMuMiAxNjQuMmgxMi45Yy03LjYtMTIuOC0xMC40LTI3LjMtOS00N3oiCiAgICAgIGNsYXNzPSJzdDExIgogICAgICBmaWxsPSJ1cmwoI1NWR0lEXzdfKSIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF84XyIgeTI9Ii0xODQuNDUiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTE3LjI5IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjczMy40OSIgeDE9IjY1My43NiI+PHN0b3Agc3RvcC1jb2xvcj0iIzA4QTI0QiIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzBBQTE0RSIgb2Zmc2V0PSIuMTY3OCIvPjxzdG9wIHN0b3AtY29sb3I9IiMwQjlFNTciIG9mZnNldD0iLjQwNDciLz48c3RvcCBzdG9wLWNvbG9yPSIjMDk5QTY3IiBvZmZzZXQ9Ii42ODI3Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzA0OTQ3RCIgb2Zmc2V0PSIuOTg5OCIvPjxzdG9wIHN0b3AtY29sb3I9IiMwNDkzN0UiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PHBhdGggZD0ibTYxNC41IDE0Mi4zYy04LjgtMzMuMy0xNy02NC43LTE4LjgtOTItMS40LTIxLjIgMS40LTMyIDguOS01MC4zaC0zNS40YzUuNyA1My45LTMuOCAxMDYuNy0xMy42IDE2Ni44LTUuNyAzNS0xMS43IDcxLjMtMTMuMiAxMDAuNi0xLjEgMjEuMSAwLjQgMzIuOCAxLjggMzloOTMuNWM0LjgtNTcuOS0xMC4zLTExNC44LTIzLjItMTY0LjF6IiBjbGFzcz0ic3QxMiIgZmlsbD0idXJsKCNTVkdJRF84XykiLz48cGF0aCBjbGFzcz0ic3QxMyIgZmlsbD0iIzFjOWE0OCIgZD0ibTY2NC42IDE1OC45Yy0xLjQgNS4xLTIuOCAxMC4xLTQuMiAxNS4xLTguOCAzMS0xNyA2MC4yLTE4LjggODUuNi0xLjQgMTkuNyAxLjQgMzQuMiA5IDQ2LjloMzNjNC4yLTUxLjgtNy4yLTEwMi4zLTE5LTE0Ny42eiIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF85XyIgeTI9Ii0xODUuOTYiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTIwLjU0IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjgxMi44MyIgeDE9IjgxMi44MyI+PHN0b3Agc3RvcC1jb2xvcj0iIzY5QTA2MCIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzYzOUQ1QyIgb2Zmc2V0PSIuMDM5ODk1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzRDOTQ0RiIgb2Zmc2V0PSIuMjE5MiIvPjxzdG9wIHN0b3AtY29sb3I9IiMzNzhFNDciIG9mZnNldD0iLjQxODQiLz48c3RvcCBzdG9wLWNvbG9yPSIjMjk4QjQ0IiBvZmZzZXQ9Ii42NTE1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzIzOEE0MyIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cGF0aCBkPSJtNjgwLjUgMGMxMC43IDU1LjMtMi41IDExMC40LTE1LjkgMTU4LjkgMTEuNyA0NS4zIDIzLjIgOTUuOCAxOC45IDE0Ny42aDM5LjZ2LTMwNi41aC00Mi42eiIgY2xhc3M9InN0MTQiIGZpbGw9InVybCgjU1ZHSURfOV8pIi8+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzEwXyIgeTI9Ii0xODUuODYiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTIwLjU0IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjY1Mi40NSIgeDE9IjY1Mi40NSI+PHN0b3Agc3RvcC1jb2xvcj0iIzA1QjVEQyIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzA0QjBENyIgb2Zmc2V0PSIuMjE5NyIvPjxzdG9wIHN0b3AtY29sb3I9IiMwNUE0QzkiIG9mZnNldD0iLjUzNzEiLz48c3RvcCBzdG9wLWNvbG9yPSIjMDU5MUI0IiBvZmZzZXQ9Ii45MTIyIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzA1OENBRSIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cGF0aCBkPSJtNTQyLjMgMjY3LjRjMS41LTI5LjQgNy41LTY1LjYgMTMuMi0xMDAuNiA5LjgtNjAuMSAxOS4zLTExMi44IDEzLjYtMTY2LjhoLTcwLjhjLTEuNCAxMS40LTIuOSAxOS4yLTEuOCA0MS44IDEuNSAzMS42IDcuNSA3MC41IDEzLjIgMTA4LjIgOC40IDU1LjQgMTYuNiAxMDguOCAxNS4xIDE1Ni40aDE5LjJjLTEuMy02LjItMi44LTE3LjktMS43LTM5eiIgY2xhc3M9InN0MTUiIGZpbGw9InVybCgjU1ZHSURfMTBfKSIvPjxwb2x5Z29uIHBvaW50cz0iMzc1LjcgMTUzLjIgMzU4LjEgMCAzNTguMSAzMDYuNCIgZmlsbD0iIzJhMzg4NiIgY2xhc3M9InN0MTYiLz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMTFfIiB5Mj0iNzcuMTM2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9Ii00LjMyODEiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgLTExOC45OCAxMjAuNTQpIiB4Mj0iNzk2LjcxIiB4MT0iNzUxLjA1Ij48c3RvcCBzdG9wLWNvbG9yPSIjNjJCMTZFIiBvZmZzZXQ9IjAiLz48c3RvcCBzdG9wLWNvbG9yPSIjODdCOTU3IiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50PjxwYXRoIGQ9Im02NDEuNiA1MC42YzEuNyAyNy4zIDEwIDU4LjcgMTguOCA5MiAxLjQgNS4zIDIuOCAxMC43IDQuMiAxNi4yIDEzLjUtNDguNCAyNi42LTEwMy41IDE1LjktMTU4LjhoLTMwYy03LjUgMTMuNi0xMC4zIDI5LjQtOC45IDUwLjZ6IiBjbGFzcz0ic3QxNyIgZmlsbD0idXJsKCNTVkdJRF8xMV8pIi8+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzEyXyIgeTI9Ii0xODkuMjgiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTEzLjcxIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjYzMS41OSIgeDE9IjU1MC40Ij48c3RvcCBzdG9wLWNvbG9yPSIjMDY5QUQ0IiBvZmZzZXQ9IjAiLz48c3RvcCBzdG9wLWNvbG9yPSIjMzBBMENFIiBvZmZzZXQ9Ii4zNTI1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzVCQjBDMCIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cGF0aCBkPSJtNTA5LjggMTUwYy01LjctMzcuNy0xMS43LTc2LjYtMTMuMi0xMDguMi0xLjEtMjIuNyAwLjQtMzAuNCAxLjgtNDEuOGgtNDEuNWMxLjUgNDAuMS0xLjUgODUuMy03IDE2MC44LTMuMSA0My41LTggMTEwLjUtNyAxNDUuN2g4Mi4xYzEuNC00Ny43LTYuOC0xMDEuMS0xNS4yLTE1Ni41eiIgY2xhc3M9InN0MTgiIGZpbGw9InVybCgjU1ZHSURfMTJfKSIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF8xM18iIHkyPSItMTg1Ljg2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9IjEyMC41NCIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAtMTE4Ljk4IDEyMC41NCkiIHgyPSI1MDUuMzMiIHgxPSI1MDUuMzMiPjxzdG9wIHN0b3AtY29sb3I9IiMxRTQ1OEUiIG9mZnNldD0iMCIvPjxzdG9wIHN0b3AtY29sb3I9IiMxRjRGOTYiIG9mZnNldD0iLjI0MTEiLz48c3RvcCBzdG9wLWNvbG9yPSIjMkI2QUFCIiBvZmZzZXQ9Ii43MjkyIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzMzN0JCOSIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cG9seWdvbiBwb2ludHM9IjM1OC4xIDMwNi40IDQxNC42IDMwNi40IDQxNC42IDAgMzU4LjEgMCAzNzUuNyAxNTMuMiIgZmlsbD0idXJsKCNTVkdJRF8xM18pIiBjbGFzcz0ic3QxOSIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF8xNF8iIHkyPSIxMjAuNTQiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iLTE4NS44NiIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAtMTE4Ljk4IDEyMC41NCkiIHgyPSI1NTQuOTIiIHgxPSI1NTQuOTIiPjxzdG9wIHN0b3AtY29sb3I9IiMzRjlBQzkiIG9mZnNldD0iMCIvPjxzdG9wIHN0b3AtY29sb3I9IiMyMDYyQTIiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PHBhdGggZD0ibTQ0OS45IDE2MC44YzUuNS03NS41IDguNS0xMjAuNiA3LTE2MC44aC00Mi4ybC0wLjEgMzA2LjRoMjguM2MtMS0zNS4xIDMuOC0xMDIuMSA3LTE0NS42eiIgY2xhc3M9InN0MjAiIGZpbGw9InVybCgjU1ZHSURfMTRfKSIvPjwvZz4KPC9zdmc+Cg==); +} + +body:after { + position: absolute; + right: 25px; + top: 36px; + width: 135px; + height: 48px; + content: ''; + background-repeat: no-repeat; + background-size: cover; + background-image: url(/static/img/bosch-logo.svg); +} + +.swagger-ui { + font-family: "Bosch Sans", sans-serif; +} + +/*custom docs*/ +.docs { + position: relative; + font-size: 14px; +} + +.docs > summary { + position: absolute; + right: 0; + top: -25px; + cursor: pointer; +} + +.docs-open:hover { + text-decoration: underline; +} + +/*Remove topbar*/ +.swagger-ui .topbar { + display: none +} + +/*Remove models view*/ +.swagger-ui .models { + display: none; +} + +/*Remove application/json select*/ +.swagger-ui .opblock .opblock-section-header > label, .swagger-ui .response-controls { + display: none; +} + +/*Remove border radius*/ +.swagger-ui .opblock, .swagger-ui .opblock .opblock-summary-method, .swagger-ui select { + border-radius: 0; + box-shadow: none; +} + +/*remove links in response*/ +.swagger-ui .response-col_links { + display: none; +} + +/*remove version*/ +.swagger-ui .info .title span { + display: none; +} + +/*separator before methods*/ +.swagger-ui .scheme-container { + box-shadow: none; + border-bottom: 1px solid var(--light-grey); +} + +/*tag separator*/ +.swagger-ui .opblock-tag { + border-bottom: 1px solid var(--light-grey); +} + +/*parameters/responses bar*/ +.swagger-ui .opblock .opblock-section-header { + box-shadow: none; + background: #fff; +} + +/*select*/ +.swagger-ui select { + background-color: var(--light-grey-w75); + border: none; + height: 36px; +} + +/*button*/ +.swagger-ui .btn { + border-radius: 0; + box-shadow: none; +} + +.swagger-ui .btn:hover { + box-shadow: none; +} + +/*authorize button */ +.swagger-ui .btn.authorize { + color: var(--light-green); + border-color: var(--light-green); +} + +.swagger-ui .btn.authorize svg { + fill: var(--light-green); +} + +/*auth inputs*/ +.swagger-ui .auth-container input[type="password"], .swagger-ui .auth-container input[type="text"] { + border-radius: 0; + box-shadow: none; + border-color: var(--light-grey); +} + +.swagger-ui .dialog-ux .modal-ux { + border-radius: 0; +} + +/*cancel button*/ +.swagger-ui .btn.cancel { + color: var(--red); + border-color: var(--red); +} + +/*download button*/ +.swagger-ui .download-contents { + border-radius: 0; + height: 28px; + width: 80px; +} + +/*model*/ +.swagger-ui .model-box { + border-radius: 0; +} + +/*execute button*/ +.swagger-ui .btn.execute { + background-color: var(--dark-blue); + border-color: var(--dark-blue); + height: 30px; + line-height: 0.7; +} + +.swagger-ui .btn-group .btn:last-child { + border-radius: 0; + height: 30px; + border-color: var(--dark-blue); +} + +.swagger-ui .btn-group .btn:first-child { + border-radius: 0; +} + +.swagger-ui .btn-group { + padding: 0 20px; +} + +/*parameter input*/ +.swagger-ui .parameters-col_description input[type="text"] { + border-radius: 0; +} + +/*required label*/ +.swagger-ui .parameter__name.required > span { + color: var(--red) !important; +} + +.swagger-ui .parameter__name.required::after { + color: var(--red); +} +/*Remove colored parameters bar*/ +.swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after { + background: none; +} + +/*code*/ +.swagger-ui .opblock-body pre.microlight { + border-radius: 0; +} + +.swagger-ui .highlight-code > .microlight { + min-height: 0; +} + +/*request body*/ +.swagger-ui textarea { + border-radius: 0; +} + +/*parameters smaller padding*/ +.swagger-ui .execute-wrapper { + padding-top: 0; + padding-bottom: 0; +} + +.swagger-ui .btn.execute { + margin-bottom: 20px; +} + +.swagger-ui .opblock-description-wrapper { + margin-top: 20px; +} + +.swagger-ui .opblock-description-wrapper { + margin-top: 5px; +} + +.opblock-section .opblock-section-request-body > div > div { + padding-top: 18px; +} + +/*response element positions*/ +.swagger-ui .model-example { + position: relative; + margin-top: 0; +} + +.swagger-ui .tab { + position: absolute; + top: -35px; + right: 0; +} + +.swagger-ui table tbody tr td { + padding: 0; +} + +.swagger-ui .renderedMarkdown p { + margin: 8px auto; +} + +/*Method colors*/ +.swagger-ui .opblock.opblock-get .opblock-summary-method { + background: var(--dark-blue); +} + +.swagger-ui .opblock.opblock-get .opblock-summary { + border-color: var(--dark-blue); +} + +.swagger-ui .opblock.opblock-get { + background: var(--dark-blue-w75); + border-color: var(--dark-blue); +} + +.swagger-ui .opblock.opblock-post .opblock-summary-method { + background: var(--dark-green); +} + +.swagger-ui .opblock.opblock-post .opblock-summary { + border-color: var(--dark-green); +} + +.swagger-ui .opblock.opblock-post { + background: var(--dark-green-w75); + border-color: var(--dark-green); +} + +.swagger-ui .opblock.opblock-put .opblock-summary-method { + background: var(--turquoise); +} + +.swagger-ui .opblock.opblock-put .opblock-summary { + border-color: var(--turquoise); +} + +.swagger-ui .opblock.opblock-put { + background: var(--turquoise-w75); + border-color: var(--turquoise); +} + +.swagger-ui .opblock.opblock-delete .opblock-summary-method { + background: var(--fuchsia); +} + +.swagger-ui .opblock.opblock-delete .opblock-summary { + border-color: var(--fuchsia); +} + +.swagger-ui .opblock.opblock-delete { + background: var(--fuchsia-w75); + border-color: var(--fuchsia); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c49a622..b43a5fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,13 +4,21 @@ "target": "es5", "outDir": "dist", "sourceMap": true, - "esModuleInterop": true + "esModuleInterop": true, + "resolveJsonModule": true, + "incremental": true, + "diagnostics": true, + "typeRoots": [ + "src/customTypings", + "node_modules/@types" + ] }, "files": [ "./node_modules/@types/node/index.d.ts" ], "include": [ - "src/**/*.ts" + "src/**/*.ts", + "src/**/*.json" ], "exclude": [ "node_modules"