* extended .gitignore for Visual Studio 2017

* creating a lib for api3 and exposing it's swagger file

* adding pilot test (for /swagger.yaml)

* implementing public GET /version

* setting api version to 3.0.0-alpha

* creating authorization skeleton + fetching some API env variables

* reusing authorization library

* implementing security

* forcing HTTPS and removing x-powered-by from response

* moving messages to constants, creating https instance fixture

* testing HTTPS requiring

* testing Date header

* testing permission check

* testing allowed operation

* refactoring + storage stub

* create architecture for generic operations

* beginning of READ operation

* tidying the code up

* basic READ part

* going further with READ operation

* DELETE operation

* handling fields parameter

* refactoring to classes

* going further with SEARCH operation

* refactoring file structure

* filtering for SEARCH operation

* preparations for fallback deduplication

* CREATE operation

* UPDATE operation

* PATCH operation

* HISTORY operation

* creating more precise variant of HISTORY operation

* autopruning

* long for timestamps in swagger

* bug fix (when search fields=srvCreated)

* creating skeleton for generic collection API test

* specific HISTORY skeleton

* distinguish between collection logical and storage name

* renaming operation to LAST MODIFIED and getting it to work

* fallback for LAST MODIFIED operation

* tidying a bit

* LAST MODIFIED documentation

* bugfix + emitting data-received

* adding some validations

* bugfix - remove 'token' parameter from filtering

* testing and debugging generic workflow

* test fix for empty db

* fixing security test fixture

* trying to fix Travis CI testing DB problem

* multiple auth callback bugfix + adding user field on authed create/update

* messages for Travis CI debugging

* messages for Travis CI debugging

* messages for Travis CI debugging

* test fix (to be prepared for future dates in db)

* test fix

* adding fallback created_at filling on each create/update

* STATUS operation with API permissions

* querying srvDate from storage + include storage version info

* bugfix of missing apiConst require

* getting mongo version with read-only user rights

* getting mongo current date with read-only user rights

* trying to diagnose travis CI timeout

* refactoring storage version caching (due to some environments problems)

* making VERSION work on empty database

* more fixes

* skipping API HTTPS test for node 8

* making code more readable using ES6 (Promises, async + await)

* extending treatments collection docs by inspecting the careportal code

* tidying existing API3 tests up to allow further grow

* tidying the authorization code up to increase readability and performance a bit

* more refactoring to ES6 and making APIv3 files structure more extendable

* normalizing incoming dates to UTC and storing utcOffset

* fixing srvDate to be of node.js server, not the mongo DB

* preparing test fixtures for permissions testing + skeleton for CREATE operation test

* intensive CREATE operation testing + minor bug fixes

* correcting the deduplication test

* more deduplication testing of CREATE operation

* adding test skeletons for other generic operations

* added variability in filtering by date, created_at, srvModified, srvCreated fields

* fixing test accordingly to previous commit

* adding new collection settings for centralized apps' settings storage

* trying to solve travis CI testing problem - adding default collections names

* another attempt to travis CI test fix

* adding some tests for READ operation

* adding custom error handler (overriding bodyparser's errors)

* securing settings collection more and updating swagger accordingly

* making HISTORY timestamp parameter more flexible + updating swagger documentation

* more testing and bug fixing

* sending only HTTP status with empty body, when there is no message + minor bug fixing

* more refactoring and testing (especially of UPDATE operation)

* PATCH testing + adding userModified field for troubleshooting purposes

* basic SEARCH operation testing

* more SEARCH operation testing

* adding alternative 'now' query parameter to 'Date' header to make GET easier

* adding 'now' to reserved query parameters for SEARCH operation

* more testing

* renaming field user to subject (and modifiedBy)

* bugfix - fixing RFC 2822 constant for moment parsing

* storageSocket: creating skeleton for new Socket.IO namespace

* storageSocket: authentication by accessToken

* storageSocket: authorizing to subscribe rooms

* storageSocket: emitting create, update and delete events

* APIv3: adding support for swagger UI at /api/v3/swagger-ui-dist

* solving some problems detected by eslint

* solving some problems detected by eslint

* APIv3: testing and debugging Socket.IO

* APIv3: testing and debugging Socket.IO

* APIv3: Socket.IO documentation

* APIv3: making the sample real

* APIv3: starting to create a simple tutorial MD file

* APIv3: small corrections

* APIv3: minor corrections after dev merge

* APIv3: adding CREATE and READ operations to the tutorial.md

* APIv3: adding SEARCH, LAST MODIFIED, UPDATE operations to the tutorial.md

* APIv3: finishing the tutorial.md

* APIv3: minor bugfix (bad location after upsert)

* APIv3: refactoring SEARCH complexity

* APIv3: refactoring mongoCollection complexity

* APIv3: refactoring complexity

* APIv3: tidying up a bit

* APIv3: refactoring security (start)

* APIv3: refactoring lastModified

* APIv3: refactoring create (start)

* APIv3: refactoring create (finish)

* APIv3: refactoring delete

* APIv3: refactoring history

* APIv3: refactoring update

* APIv3: refactoring patch

* APIv3: refactoring read

* APIv3: refactoring search + removing deprecated authorizationBuilder

* APIv3: adding best practise for identifier constructing

* APIv3: refactoring and enhancing the validation (immutable fields)

* APIv3: adding security.md documentation file

* APIv3: refactoring - splitting index.js into multiple files

* APIv3: calculating identifier on server side + deduplicating

* APIv3: refactoring cosmetics

* APIv3: updating the documentation

* APIv3: making basic and security tests more readable using async/await

* APIv3: making the rest of tests more readable using async/await

* APIv3: adapting test of previous API

* APIv3: adapting test of previous API
This commit is contained in:
Petr Ondrusek
2019-10-09 22:53:55 +03:00
committed by Sulka Haro
parent dfbcf62e8e
commit 2dd576a629
61 changed files with 7578 additions and 9 deletions
+9
View File
@@ -9,6 +9,15 @@ module.exports = {
"commonjs": true,
"es6": true,
"node": true,
"mocha": true,
"jquery": true
},
"rules": {
"no-unused-vars": [
"error",
{
"varsIgnorePattern": "should|expect"
}
]
}
};
+6
View File
@@ -24,3 +24,9 @@ npm-debug.log
*.heapsnapshot
/tmp
/.vs
/cgm-remote-monitor.njsproj
/cgm-remote-monitor.sln
/obj/Debug
/bin
/*.bat
+8 -5
View File
@@ -114,11 +114,12 @@ function create (env, ctx) {
});
}
///////////////////////////////////////////////////
// api and json object variables
///////////////////////////////////////////////////
var api = require('./lib/api/')(env, ctx);
var ddata = require('./lib/data/endpoints')(env, ctx);
///////////////////////////////////////////////////
// api and json object variables
///////////////////////////////////////////////////
var api = require('./lib/api/')(env, ctx);
var api3 = require('./lib/api3/')(env, ctx);
var ddata = require('./lib/data/endpoints')(env, ctx);
app.use(compression({
filter: function shouldCompress (req, res) {
@@ -172,6 +173,8 @@ function create (env, ctx) {
app.use('/api/v2/authorization', ctx.authorization.endpoints);
app.use('/api/v2/ddata', ddata);
app.use('/api/v3', api3);
// pebble data
app.get('/pebble', ctx.pebble);
+1
View File
@@ -112,6 +112,7 @@ function setStorage() {
env.authentication_collections_prefix = readENV('MONGO_AUTHENTICATION_COLLECTIONS_PREFIX', 'auth_');
env.treatments_collection = readENV('MONGO_TREATMENTS_COLLECTION', 'treatments');
env.profile_collection = readENV('MONGO_PROFILE_COLLECTION', 'profile');
env.settings_collection = readENV('MONGO_SETTINGS_COLLECTION', 'settings');
env.devicestatus_collection = readENV('MONGO_DEVICESTATUS_COLLECTION', 'devicestatus');
env.food_collection = readENV('MONGO_FOOD_COLLECTION', 'food');
env.activity_collection = readENV('MONGO_ACTIVITY_COLLECTION', 'activity');
+51
View File
@@ -0,0 +1,51 @@
{
"API3_VERSION": "3.0.0-alpha",
"API3_SECURITY_ENABLE": true,
"API3_TIME_SKEW_TOLERANCE": 5,
"API3_DEDUP_FALLBACK_ENABLED": true,
"API3_CREATED_AT_FALLBACK_ENABLED": true,
"API3_MAX_LIMIT": 1000,
"HTTP": {
"OK": 200,
"CREATED": 201,
"NO_CONTENT": 204,
"NOT_MODIFIED": 304,
"BAD_REQUEST": 400,
"UNAUTHORIZED": 401,
"FORBIDDEN": 403,
"NOT_FOUND": 404,
"GONE": 410,
"PRECONDITION_FAILED": 412,
"INTERNAL_ERROR": 500
},
"MSG": {
"HTTP_400_BAD_LAST_MODIFIED": "Bad or missing Last-Modified header/parameter",
"HTTP_400_BAD_LIMIT": "Parameter limit out of tolerance",
"HTTP_400_BAD_REQUEST_BODY": "Bad or missing request body",
"HTTP_400_BAD_FIELD_IDENTIFIER": "Bad or missing identifier field",
"HTTP_400_BAD_FIELD_DATE": "Bad or missing date field",
"HTTP_400_BAD_FIELD_UTC": "Bad or missing utcOffset field",
"HTTP_400_BAD_FIELD_APP": "Bad or missing app field",
"HTTP_400_BAD_SKIP": "Parameter skip out of tolerance",
"HTTP_400_SORT_SORT_DESC": "Parameters sort and sort_desc cannot be combined",
"HTTP_400_UNSUPPORTED_FILTER_OPERATOR": "Unsupported filter operator {0}",
"HTTP_400_IMMUTABLE_FIELD": "Field {0} cannot be modified by the client",
"HTTP_401_BAD_DATE": "Bad Date header",
"HTTP_401_BAD_TOKEN": "Bad access token or JWT",
"HTTP_401_DATE_OUT_OF_TOLERANCE": "Date header out of tolerance",
"HTTP_401_MISSING_DATE": "Missing Date header",
"HTTP_401_MISSING_OR_BAD_TOKEN": "Missing or bad access token or JWT",
"HTTP_403_MISSING_PERMISSION": "Missing permission {0}",
"HTTP_403_NOT_USING_HTTPS": "Not using SSL/TLS",
"HTTP_500_INTERNAL_ERROR": "Internal Server Error",
"STORAGE_ERROR": "Database error",
"SOCKET_MISSING_OR_BAD_ACCESS_TOKEN": "Missing or bad accessToken",
"SOCKET_UNAUTHORIZED_TO_ANY": "Unauthorized to receive any collection"
},
"MIN_TIMESTAMP": 946684800000,
"MIN_UTC_OFFSET": -1440,
"MAX_UTC_OFFSET": 1440
}
+48
View File
@@ -0,0 +1,48 @@
# APIv3: Security
### Enforcing HTTPS
APIv3 is ment to run only under SSL version of HTTP protocol, which provides:
- **message secrecy** - once the secure channel between client and server is closed the communication cannot be eavesdropped by any third party
- **message consistency** - each request/response is protected against modification by any third party (any forgery would be detected)
- **authenticity of identities** - once the client and server establish the secured channel, it is guaranteed that the identity of the client or server does not change during the whole session
HTTPS (in use with APIv3) does not address the true identity of the client, but ensures the correct identity of the server. Furthermore, HTTPS does not prevent the resending of previously intercepted encrypted messages by an attacker.
---
### Authentication and authorization
In APIv3, *API_SECRET* can no longer be used for authentication or authorization. Instead, a roles/permissions security model is used, which is managed in the *Admin tools* section of the web application.
The identity of the client is represented by the *subject* to whom the access level is set by assigning security *roles*. One or more *permissions* can be assigned to each role. Permissions are used in an [Apache Shiro-like style](http://shiro.apache.org/permissions.html "Apache Shiro-like style").
For each security *subject*, the system automatically generates an *access token* that is difficult to guess since it is derived from the secret *API_SECRET*. The *access token* must be included in every secured API operation to decode the client's identity and determine its authorization level. In this way, it is then possible to resolve whether the client has the permission required by a particular API operation.
There are two ways to authorize API calls:
- use `token` query parameter to pass the *access token*, eg. `token=testreadab-76eaff2418bfb7e0`
- use so-called [JSON Web Tokens](https://jwt.io "JSON Web Tokens")
- at first let the `/api/v2/authorization/request` generates you a particular JWT, eg. `GET https://nsapiv3.herokuapp.com/api/v2/authorization/request/testreadab-76eaff2418bfb7e0`
- then, to each secure API operation attach a JWT token in the HTTP header, eg. `Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NUb2tlbiI6InRlc3RyZWFkYWItNzZlYWZmMjQxOGJmYjdlMCIsImlhdCI6MTU2NTAzOTczMSwiZXhwIjoxNTY1MDQzMzMxfQ.Y-OFtFJ-gZNJcnZfm9r4S7085Z7YKVPiaQxuMMnraVk` (until the JWT expires)
---
### Client timestamps
As previously mentioned, a potential attacker cannot decrypt the captured messages, but he can send them back to the client/server at any later time. APIv3 is partially preventing this by the temporal validity of each secured API call.
The client must include his current timestamp to each call so that the server can compare it against its clock. If the timestamp difference is not within the limit, the request is considered invalid. The tolerance limit is set in minutes in the `API3_TIME_SKEW_TOLERANCE` environment variable.
There are two ways to include the client timestamp to the call:
- use `now` query parameter with UNIX epoch millisecond timestamp, eg. `now=1565041446908`
- add HTTP `Date` header to the request, eg. `Date: Sun, 12 May 2019 07:49:58 GMT`
The client can check each server response in the same way, because each response contains a server timestamp in the HTTP *Date* header as well.
---
APIv3 security is enabled by default, but it can be completely disabled for development and debugging purposes by setting the web environment variable `API3_SECURITY_ENABLE=false`.
This setting is hazardous and it is strongly discouraged to be used for production purposes!
+142
View File
@@ -0,0 +1,142 @@
# APIv3: Socket.IO storage modifications channel
APIv3 has the ability to broadcast events about all created, edited and deleted documents, using Socket.IO library.
This provides a real-time data exchange experience in combination with API REST operations.
### Complete sample client code
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>APIv3 Socket.IO sample</title>
<link rel="icon" href="images/favicon.png" />
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.2.0/socket.io.js"></script>
<script>
const socket = io('https://nsapiv3.herokuapp.com/storage');
socket.on('connect', function () {
socket.emit('subscribe', {
accessToken: 'testadmin-ad3b1f9d7b3f59d5',
collections: [ 'entries', 'treatments' ]
}, function (data) {
if (data.success) {
console.log('subscribed for collections', data.collections);
}
else {
console.error(data.message);
}
});
});
socket.on('create', function (data) {
console.log(`${data.colName}:created document`, data.doc);
});
socket.on('update', function (data) {
console.log(`${data.colName}:updated document`, data.doc);
});
socket.on('delete', function (data) {
console.log(`${data.colName}:deleted document with identifier`, data.identifier);
});
</script>
</body>
</html>
```
**Important notice: Only changes made via APIv3 are being broadcasted. All direct database or APIv1 modifications are not included by this channel.**
### Subscription (authorization)
The client must first subscribe to the channel that is exposed at `storage` namespace, ie the `/storage` subadress of the base Nightscout's web address (without `/api/v3` subaddress).
```javascript
const socket = io('https://nsapiv3.herokuapp.com/storage');
```
Subscription is requested by emitting `subscribe` event to the server, while including document with parameters:
* `accessToken`: required valid accessToken of the security subject, which has been prepared in *Admin Tools* of Nightscout.
* `collections`: optional array of collections which the client wants to subscribe to, by default all collections are requested)
```javascript
socket.on('connect', function () {
socket.emit('subscribe', {
accessToken: 'testadmin-ad3b1f9d7b3f59d5',
collections: [ 'entries', 'treatments' ]
},
```
On the server, the subject is first identified and authenticated (by the accessToken) and then a verification takes place, if the subject has read access to each required collection.
An exception is the `settings` collection for which `api:settings:admin` permission is required, for all other collections `api:<collection>:read` permission is required.
If the authentication was successful and the client has read access to at least one collection, `success` = `true` is set in the response object and the field `collections` contains an array of collections which were actually subscribed (granted).
In other case `success` = `false` is set in the response object and the field `message` contains an error message.
```javascript
function (data) {
if (data.success) {
console.log('subscribed for collections', data.collections);
}
else {
console.error(data.message);
}
});
});
```
### Receiving events
After the successful subscription the client can start listening to `create`, `update` and/or `delete` events of the socket.
##### create
`create` event fires each time a new document is inserted into the storage, regardless of whether it was CREATE or UPDATE operation of APIv3 (both of these operations are upserting/deduplicating, so they are "insert capable"). If the document already existed in the storage, the `update` event would be fired instead.
The received object contains:
* `colName` field with the name of the affected collection
* the inserted document in `doc` field
```javascript
socket.on('create', function (data) {
console.log(`${data.colName}:created document`, data.doc);
});
```
##### update
`update` event fires each time an existing document is modified in the storage, regardless of whether it was CREATE, UPDATE or PATCH operation of APIv3 (all of these operations are "update capable"). If the document did not yet exist in the storage, the `create` event would be fired instead.
The received object contains:
* `colName` field with the name of the affected collection
* the new version of the modified document in `doc` field
```javascript
socket.on('update', function (data) {
console.log(`${data.colName}:updated document`, data.doc);
});
```
##### delete
`delete` event fires each time an existing document is deleted in the storage, regardless of whether it was "soft" (marking as invalid) or permanent deleting.
The received object contains:
* `colName` field with the name of the affected collection
* the identifier of the deleted document in the `identifier` field
```javascript
socket.on('delete', function (data) {
console.log(`${data.colName}:deleted document with identifier`, data.identifier);
});
```
+329
View File
@@ -0,0 +1,329 @@
# APIv3: Basics tutorial
Nightscout API v3 is a component of [cgm-remote-monitor](https://github.com/nightscout/cgm-remote-monitor) project.
It aims to provide lightweight, secured and HTTP REST compliant interface for your T1D treatment data exchange.
There is a list of REST operations that the API v3 offers (inside `/api/v3` relative URL namespace), we will briefly introduce them in this file.
Each NS instance with API v3 contains self-included OpenAPI specification at [/api/v3/swagger-ui-dist/](https://nsapiv3.herokuapp.com/api/v3/swagger-ui-dist/) relative URL.
---
### VERSION
[VERSION](https://nsapiv3.herokuapp.com/api/v3/swagger-ui-dist/#/other/get_version) operation gets you basic information about software packages versions.
It is public (there is no need to add authorization parameters/headers).
Sample GET `/version` client code (to get actual versions):
```javascript
const request = require('request');
request('https://nsapiv3.herokuapp.com/api/v3/version',
(error, response, body) => console.log(body));
```
Sample result:
```javascript
{
"version":"0.12.2",
"apiVersion":"3.0.0-alpha",
"srvDate":1564386001772,
"storage":{
"storage":"mongodb",
"version":"3.6.12"
}
}
```
---
### STATUS
[STATUS](https://nsapiv3.herokuapp.com/api/v3/swagger-ui-dist/#/other/get_status) operation gets you basic information about software packages versions.
It is public (there is no need to add authorization parameters/headers).
Sample GET `/status` client code (to get my actual permissions):
```javascript
const request = require('request');
const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
request(`https://nsapiv3.herokuapp.com/api/v3/status?${auth}`,
(error, response, body) => console.log(body));
```
Sample result:
```javascript
{
"version":"0.12.2",
"apiVersion":"3.0.0-alpha",
"srvDate":1564391740738,
"storage":{
"storage":"mongodb",
"version":"3.6.12"
},
"apiPermissions":{
"devicestatus":"crud",
"entries":"crud",
"food":"crud",
"profile":"crud",
"settings":"crud",
"treatments":"crud"
}
}
```
`"crud"` represents create + read + update + delete permissions for the collection.
---
### SEARCH
[SEARCH](https://nsapiv3insecure.herokuapp.com/api/v3/swagger-ui-dist/index.html#/generic/SEARCH) operation filters, sorts, paginates and projects documents from the collection.
Sample GET `/entries` client code (to retrieve last 3 BG values):
```javascript
const request = require('request');
const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
request(`https://nsapiv3.herokuapp.com/api/v3/entries?${auth}&sort$desc=date&limit=3&fields=dateString,sgv,direction`,
(error, response, body) => console.log(body));
```
Sample result:
```
[
{
"dateString":"2019-07-30T02:24:50.434+0200",
"sgv":115,
"direction":"FortyFiveDown"
},
{
"dateString":"2019-07-30T02:19:50.374+0200",
"sgv":121,
"direction":"FortyFiveDown"
},
{
"dateString":"2019-07-30T02:14:50.450+0200",
"sgv":129,
"direction":"FortyFiveDown"
}
]
```
---
### CREATE
[CREATE](https://nsapiv3.herokuapp.com/api/v3/swagger-ui-dist/#/generic/post__collection_) operation inserts a new document into the collection.
Sample POST `/treatments` client code:
```javascript
const request = require('request');
const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
const doc = {
date: 1564591511232, // (new Date()).getTime(),
app: 'AndroidAPS',
device: 'Samsung XCover 4-861536030196001',
eventType: 'Correction Bolus',
insulin: 0.3
};
request({
method: 'post',
body: doc,
json: true,
url: `https://nsapiv3.herokuapp.com/api/v3/treatments?${auth}`
},
(error, response, body) => console.log(response.headers.location));
```
Sample result:
```
/api/v3/treatments/95e1a6e3-1146-5d6a-a3f1-41567cae0895
```
---
### READ
[READ](https://nsapiv3.herokuapp.com/api/v3/swagger-ui-dist/#/generic/get__collection___identifier_) operation retrieves you a single document from the collection by its identifier.
Sample GET `/treatments/{identifier}` client code:
```javascript
const request = require('request');
const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
const identifier = '95e1a6e3-1146-5d6a-a3f1-41567cae0895';
request(`https://nsapiv3.herokuapp.com/api/v3/treatments/${identifier}?${auth}`,
(error, response, body) => console.log(body));
```
Sample result:
```
{
"date":1564591511232,
"app":"AndroidAPS",
"device":"Samsung XCover 4-861536030196001",
"eventType":"Correction Bolus",
"insulin":0.3,
"identifier":"95e1a6e3-1146-5d6a-a3f1-41567cae0895",
"utcOffset":0,
"created_at":"2019-07-31T16:45:11.232Z",
"srvModified":1564591627732,
"srvCreated":1564591511711,
"subject":"test-admin"
}
```
---
### LAST MODIFIED
[LAST MODIFIED](https://nsapiv3insecure.herokuapp.com/api/v3/swagger-ui-dist/index.html#/other/LAST-MODIFIED) operation finds the date of last modification for each collection.
Sample GET `/lastModified` client code (to get latest modification dates):
```javascript
const request = require('request');
const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
request(`https://nsapiv3.herokuapp.com/api/v3/lastModified?${auth}`,
(error, response, body) => console.log(body));
```
Sample result:
```javascript
{
"srvDate":1564591783202,
"collections":{
"devicestatus":1564591490074,
"entries":1564591486801,
"profile":1548524042744,
"treatments":1564591627732
}
}
```
---
### UPDATE
[UPDATE](https://nsapiv3insecure.herokuapp.com/api/v3/swagger-ui-dist/index.html#/generic/put__collection___identifier_) operation updates existing document in the collection.
Sample PUT `/treatments/{identifier}` client code (to update `insulin` from 0.3 to 0.4):
```javascript
const request = require('request');
const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
const identifier = '95e1a6e3-1146-5d6a-a3f1-41567cae0895';
const doc = {
date: 1564591511232,
app: 'AndroidAPS',
device: 'Samsung XCover 4-861536030196001',
eventType: 'Correction Bolus',
insulin: 0.4
};
request({
method: 'put',
body: doc,
json: true,
url: `https://nsapiv3.herokuapp.com/api/v3/treatments/${identifier}?${auth}`
},
(error, response, body) => console.log(response.statusCode));
```
Sample result:
```
204
```
---
### PATCH
[PATCH](https://nsapiv3insecure.herokuapp.com/api/v3/swagger-ui-dist/index.html#/generic/patch__collection___identifier_) operation partially updates existing document in the collection.
Sample PATCH `/treatments/{identifier}` client code (to update `insulin` from 0.4 to 0.5):
```javascript
const request = require('request');
const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
const identifier = '95e1a6e3-1146-5d6a-a3f1-41567cae0895';
const doc = {
insulin: 0.5
};
request({
method: 'patch',
body: doc,
json: true,
url: `https://nsapiv3.herokuapp.com/api/v3/treatments/${identifier}?${auth}`
},
(error, response, body) => console.log(response.statusCode));
```
Sample result:
```
204
```
---
### DELETE
[DELETE](https://nsapiv3insecure.herokuapp.com/api/v3/swagger-ui-dist/index.html#/generic/delete__collection___identifier_) operation deletes existing document from the collection.
Sample DELETE `/treatments/{identifier}` client code (to update `insulin` from 0.4 to 0.5):
```javascript
const request = require('request');
const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
const identifier = '95e1a6e3-1146-5d6a-a3f1-41567cae0895';
request({
method: 'delete',
url: `https://nsapiv3.herokuapp.com/api/v3/treatments/${identifier}?${auth}`
},
(error, response, body) => console.log(response.statusCode));
```
Sample result:
```
204
```
---
### HISTORY
[HISTORY](https://nsapiv3insecure.herokuapp.com/api/v3/swagger-ui-dist/index.html#/generic/HISTORY2) operation queries all changes since the timestamp.
Sample HISTORY `/treatments/history/{lastModified}` client code:
```javascript
const request = require('request');
const auth = `token=testadmin-ad3b1f9d7b3f59d5&now=${new Date().getTime()}`;
const lastModified = 1564521267421;
request(`https://nsapiv3.herokuapp.com/api/v3/treatments/history/${lastModified}?${auth}`,
(error, response, body) => console.log(response.body));
```
Sample result:
```
[
{
"date":1564521267421,
"app":"AndroidAPS",
"device":"Samsung XCover 4-861536030196001",
"eventType":"Correction Bolus",
"insulin":0.5,
"utcOffset":0,
"created_at":"2019-07-30T21:14:27.421Z",
"identifier":"95e1a6e3-1146-5d6a-a3f1-41567cae0895",
"srvModified":1564592440416,
"srvCreated":1564592334853,
"subject":"test-admin",
"modifiedBy":"test-admin",
"isValid":false
},
{
"date":1564592545299,
"app":"AndroidAPS",
"device":"Samsung XCover 4-861536030196001",
"eventType":"Snack Bolus",
"carbs":10,
"identifier":"267c43c2-f629-5191-a542-4f410c69e486",
"utcOffset":0,
"created_at":"2019-07-31T17:02:25.299Z",
"srvModified":1564592545781,
"srvCreated":1564592545781,
"subject":"test-admin"
}
]
```
Notice the `"isValid":false` field marking the deletion of the document.
+193
View File
@@ -0,0 +1,193 @@
'use strict';
const apiConst = require('../const.json')
, _ = require('lodash')
, dateTools = require('../shared/dateTools')
, opTools = require('../shared/operationTools')
, stringTools = require('../shared/stringTools')
, CollectionStorage = require('../storage/mongoCollection')
, searchOperation = require('./search/operation')
, createOperation = require('./create/operation')
, readOperation = require('./read/operation')
, updateOperation = require('./update/operation')
, patchOperation = require('./patch/operation')
, deleteOperation = require('./delete/operation')
, historyOperation = require('./history/operation')
;
/**
* Generic collection (abstraction over each collection specifics)
* @param {string} colName - name of the collection inside the storage system
* @param {function} fallbackGetDate - function that tries to create srvModified virtually from other fields of document
* @param {Array} dedupFallbackFields - fields that all need to be matched to identify document via fallback deduplication
* @param {function} fallbackHistoryFilter - function that creates storage filter for all newer records (than the timestamp from first function parameter)
*/
function Collection ({ ctx, env, app, colName, storageColName, fallbackGetDate, dedupFallbackFields,
fallbackDateField }) {
const self = this;
self.colName = colName;
self.fallbackGetDate = fallbackGetDate;
self.dedupFallbackFields = app.get('API3_DEDUP_FALLBACK_ENABLED') ? dedupFallbackFields : [];
self.autoPruneDays = app.setENVTruthy('API3_AUTOPRUNE_' + colName.toUpperCase());
self.nextAutoPrune = new Date();
self.storage = new CollectionStorage(ctx, env, storageColName);
self.fallbackDateField = fallbackDateField;
self.mapRoutes = function mapRoutes () {
const prefix = '/' + colName
, prefixId = prefix + '/:identifier'
, prefixHistory = prefix + '/history'
;
// GET /{collection}
app.get(prefix, searchOperation(ctx, env, app, self));
// POST /{collection}
app.post(prefix, createOperation(ctx, env, app, self));
// GET /{collection}/history
app.get(prefixHistory, historyOperation(ctx, env, app, self));
// GET /{collection}/history
app.get(prefixHistory + '/:lastModified', historyOperation(ctx, env, app, self));
// GET /{collection}/{identifier}
app.get(prefixId, readOperation(ctx, env, app, self));
// PUT /{collection}/{identifier}
app.put(prefixId, updateOperation(ctx, env, app, self));
// PATCH /{collection}/{identifier}
app.patch(prefixId, patchOperation(ctx, env, app, self));
// DELETE /{collection}/{identifier}
app.delete(prefixId, deleteOperation(ctx, env, app, self));
};
/**
* Parse limit (max document count) from query string
*/
self.parseLimit = function parseLimit (req, res) {
const maxLimit = app.get('API3_MAX_LIMIT');
let limit = maxLimit;
if (req.query.limit) {
if (!isNaN(req.query.limit) && req.query.limit > 0 && req.query.limit <= maxLimit) {
limit = parseInt(req.query.limit);
}
else {
opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_LIMIT);
return null;
}
}
return limit;
};
/**
* Fetch modified date from document (with possible fallback and back-fill to srvModified/srvCreated)
* @param {Object} doc - document loaded from database
*/
self.resolveDates = function resolveDates (doc) {
let modifiedDate;
try {
if (doc.srvModified) {
modifiedDate = new Date(doc.srvModified);
}
else {
if (typeof (self.fallbackGetDate) === 'function') {
modifiedDate = self.fallbackGetDate(doc);
if (modifiedDate) {
doc.srvModified = modifiedDate.getTime();
}
}
}
if (doc.srvModified && !doc.srvCreated) {
doc.srvCreated = modifiedDate.getTime();
}
}
catch (error) {
console.warn(error);
}
return modifiedDate;
};
/**
* Deletes old documents from the collection if enabled (for this collection)
* in the background (asynchronously)
* */
self.autoPrune = function autoPrune () {
if (!stringTools.isNumberInString(self.autoPruneDays))
return;
const autoPruneDays = parseFloat(self.autoPruneDays);
if (autoPruneDays <= 0)
return;
if (new Date() > self.nextAutoPrune) {
const deleteBefore = new Date(new Date().getTime() - (autoPruneDays * 24 * 3600 * 1000));
const filter = [
{ field: 'srvCreated', operator: 'lt', value: deleteBefore.getTime() },
{ field: 'created_at', operator: 'lt', value: deleteBefore.toISOString() },
{ field: 'date', operator: 'lt', value: deleteBefore.getTime() }
];
// let's autoprune asynchronously (we won't wait for the result)
self.storage.deleteManyOr(filter, function deleteDone (err, result) {
if (err || !result) {
console.error(err);
}
if (result.deleted) {
console.info('Auto-pruned ' + result.deleted + ' documents from ' + self.colName + ' collection ');
}
});
self.nextAutoPrune = new Date(new Date().getTime() + (3600 * 1000));
}
};
/**
* Parse date and utcOffset + optional created_at fallback
* @param {Object} doc
*/
self.parseDate = function parseDate (doc) {
if (!_.isEmpty(doc)) {
let values = app.get('API3_CREATED_AT_FALLBACK_ENABLED')
? [doc.date, doc.created_at]
: [doc.date];
let m = dateTools.parseToMoment(values);
if (m && m.isValid()) {
doc.date = m.valueOf();
if (typeof doc.utcOffset === 'undefined') {
doc.utcOffset = m.utcOffset();
}
if (app.get('API3_CREATED_AT_FALLBACK_ENABLED')) {
doc.created_at = m.toISOString();
}
else {
if (doc.created_at)
delete doc.created_at;
}
}
}
}
}
module.exports = Collection;
+45
View File
@@ -0,0 +1,45 @@
'use strict';
const apiConst = require('../../const.json')
, security = require('../../security')
, validate = require('./validate.js')
;
/**
* Insert new document into the collection
* @param {Object} opCtx
* @param {Object} doc
*/
async function insert (opCtx, doc) {
const { ctx, auth, col, req, res } = opCtx;
await security.demandPermission(opCtx, `api:${col.colName}:create`);
if (validate(opCtx, doc) !== true)
return;
const now = new Date;
doc.srvModified = now.getTime();
doc.srvCreated = doc.srvModified;
if (auth && auth.subject && auth.subject.name) {
doc.subject = auth.subject.name;
}
const identifier = await col.storage.insertOne(doc);
if (!identifier)
throw new Error('empty identifier');
res.setHeader('Last-Modified', now.toUTCString());
res.setHeader('Location', `${req.baseUrl}${req.path}/${identifier}`);
res.status(apiConst.HTTP.CREATED).send({ });
ctx.bus.emit('storage-socket-create', { colName: col.colName, doc });
col.autoPrune();
ctx.bus.emit('data-received');
}
module.exports = insert;
+63
View File
@@ -0,0 +1,63 @@
'use strict';
const _ = require('lodash')
, apiConst = require('../../const.json')
, security = require('../../security')
, insert = require('./insert')
, replace = require('../update/replace')
, opTools = require('../../shared/operationTools')
;
/**
* CREATE: Inserts a new document into the collection
*/
async function create (opCtx) {
const { col, req, res } = opCtx;
const doc = req.body;
if (_.isEmpty(doc)) {
return opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_REQUEST_BODY);
}
col.parseDate(doc);
opTools.resolveIdentifier(doc);
const identifyingFilter = col.storage.identifyingFilter(doc.identifier, doc, col.dedupFallbackFields);
const result = await col.storage.findOneFilter(identifyingFilter, { });
if (!result)
throw new Error('empty result');
if (result.length > 0) {
const storageDoc = result[0];
await replace(opCtx, doc, storageDoc, { isDeduplication: true });
}
else {
await insert(opCtx, doc);
}
}
function createOperation (ctx, env, app, col) {
return async function operation (req, res) {
const opCtx = { app, ctx, env, col, req, res };
try {
opCtx.auth = await security.authenticate(opCtx);
await create(opCtx);
} catch (err) {
console.error(err);
if (!res.headersSent) {
return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
}
}
};
}
module.exports = createOperation;
+26
View File
@@ -0,0 +1,26 @@
'use strict';
const apiConst = require('../../const.json')
, stringTools = require('../../shared/stringTools')
, opTools = require('../../shared/operationTools')
;
/**
* Validation of document to create
* @param {Object} opCtx
* @param {Object} doc
* @returns string with error message if validation fails, true in case of success
*/
function validate (opCtx, doc) {
const { res } = opCtx;
if (typeof(doc.identifier) !== 'string' || stringTools.isNullOrWhitespace(doc.identifier)) {
return opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_FIELD_IDENTIFIER);
}
return opTools.validateCommon(doc, res);
}
module.exports = validate;
+93
View File
@@ -0,0 +1,93 @@
'use strict';
const apiConst = require('../../const.json')
, security = require('../../security')
, opTools = require('../../shared/operationTools')
;
/**
* DELETE: Deletes a document from the collection
*/
async function doDelete (opCtx) {
const { col, req } = opCtx;
await security.demandPermission(opCtx, `api:${col.colName}:delete`);
if (req.query.permanent && req.query.permanent === "true") {
await deletePermanently(opCtx);
} else {
await markAsDeleted(opCtx);
}
}
async function deletePermanently (opCtx) {
const { ctx, col, req, res } = opCtx;
const identifier = req.params.identifier;
const result = await col.storage.deleteOne(identifier);
if (!result)
throw new Error('empty result');
if (!result.deleted) {
return res.status(apiConst.HTTP.NOT_FOUND).end();
}
col.autoPrune();
ctx.bus.emit('storage-socket-delete', { colName: col.colName, identifier });
ctx.bus.emit('data-received');
return res.status(apiConst.HTTP.NO_CONTENT).end();
}
async function markAsDeleted (opCtx) {
const { ctx, col, req, res, auth } = opCtx;
const identifier = req.params.identifier;
const setFields = { 'isValid': false, 'srvModified': (new Date).getTime() };
if (auth && auth.subject && auth.subject.name) {
setFields.modifiedBy = auth.subject.name;
}
const result = await col.storage.updateOne(identifier, setFields);
if (!result)
throw new Error('empty result');
if (!result.updated) {
return res.status(apiConst.HTTP.NOT_FOUND).end();
}
ctx.bus.emit('storage-socket-delete', { colName: col.colName, identifier });
col.autoPrune();
ctx.bus.emit('data-received');
return res.status(apiConst.HTTP.NO_CONTENT).end();
}
function deleteOperation (ctx, env, app, col) {
return async function operation (req, res) {
const opCtx = { app, ctx, env, col, req, res };
try {
opCtx.auth = await security.authenticate(opCtx);
await doDelete(opCtx);
} catch (err) {
console.error(err);
if (!res.headersSent) {
return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
}
}
};
}
module.exports = deleteOperation;
+151
View File
@@ -0,0 +1,151 @@
'use strict';
const dateTools = require('../../shared/dateTools')
, apiConst = require('../../const.json')
, security = require('../../security')
, opTools = require('../../shared/operationTools')
, FieldsProjector = require('../../shared/fieldsProjector')
, _ = require('lodash')
;
/**
* HISTORY: Retrieves incremental changes since timestamp
*/
async function history (opCtx, fieldsProjector) {
const { req, res, col } = opCtx;
let filter = parseFilter(opCtx)
, limit = col.parseLimit(req, res)
, projection = fieldsProjector.storageProjection()
, sort = prepareSort()
, skip = 0
, onlyValid = false
, logicalOperator = 'or'
;
if (filter !== null && limit !== null && projection !== null) {
const result = await col.storage.findMany(filter
, sort
, limit
, skip
, projection
, onlyValid
, logicalOperator);
if (!result)
throw new Error('empty result');
if (result.length === 0) {
return res.status(apiConst.HTTP.NO_CONTENT).end();
}
_.each(result, col.resolveDates);
const srvModifiedValues = _.map(result, function mapSrvModified (item) {
return item.srvModified;
})
, maxSrvModified = _.max(srvModifiedValues);
res.setHeader('Last-Modified', (new Date(maxSrvModified)).toUTCString());
res.setHeader('ETag', 'W/"' + maxSrvModified + '"');
_.each(result, fieldsProjector.applyProjection);
res.status(apiConst.HTTP.OK).send(result);
}
}
/**
* Parse history filtering criteria from Last-Modified header
*/
function parseFilter (opCtx) {
const { req, res } = opCtx;
let lastModified = null
, lastModifiedParam = req.params.lastModified
, operator = null;
if (lastModifiedParam) {
// using param in URL as a source of timestamp
const m = dateTools.parseToMoment(lastModifiedParam);
if (m === null || !m.isValid()) {
opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_LAST_MODIFIED);
return null;
}
lastModified = m.toDate();
operator = 'gt';
}
else {
// using request HTTP header as a source of timestamp
const lastModifiedHeader = req.get('Last-Modified');
if (!lastModifiedHeader) {
opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_LAST_MODIFIED);
return null;
}
try {
lastModified = dateTools.floorSeconds(new Date(lastModifiedHeader));
} catch (err) {
opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_LAST_MODIFIED);
return null;
}
operator = 'gte';
}
return [
{ field: 'srvModified', operator: operator, value: lastModified.getTime() },
{ field: 'created_at', operator: operator, value: lastModified.toISOString() },
{ field: 'date', operator: operator, value: lastModified.getTime() }
];
}
/**
* Prepare sorting for storage query
*/
function prepareSort () {
return {
srvModified: 1,
created_at: 1,
date: 1
};
}
function historyOperation (ctx, env, app, col) {
return async function operation (req, res) {
const opCtx = { app, ctx, env, col, req, res };
try {
opCtx.auth = await security.authenticate(opCtx);
if (col.colName === 'settings') {
await security.demandPermission(opCtx, `api:${col.colName}:admin`);
} else {
await security.demandPermission(opCtx, `api:${col.colName}:read`);
}
const fieldsProjector = new FieldsProjector(req.query.fields);
await history(opCtx, fieldsProjector);
} catch (err) {
console.error(err);
if (!res.headersSent) {
return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
}
}
};
}
module.exports = historyOperation;
+118
View File
@@ -0,0 +1,118 @@
'use strict';
const _ = require('lodash')
, apiConst = require('../../const.json')
, security = require('../../security')
, validate = require('./validate.js')
, opTools = require('../../shared/operationTools')
, dateTools = require('../../shared/dateTools')
, FieldsProjector = require('../../shared/fieldsProjector')
;
/**
* PATCH: Partially updates document in the collection
*/
async function patch (opCtx) {
const { req, res, col } = opCtx;
const doc = req.body;
if (_.isEmpty(doc)) {
return opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_REQUEST_BODY);
}
await security.demandPermission(opCtx, `api:${col.colName}:update`);
col.parseDate(doc);
const identifier = req.params.identifier
, identifyingFilter = col.storage.identifyingFilter(identifier);
const result = await col.storage.findOneFilter(identifyingFilter, { });
if (!result)
throw new Error('result empty');
if (result.length > 0) {
const storageDoc = result[0];
if (storageDoc.isValid === false) {
return res.status(apiConst.HTTP.GONE).end();
}
const modifiedDate = col.resolveDates(storageDoc)
, ifUnmodifiedSince = req.get('If-Unmodified-Since');
if (ifUnmodifiedSince
&& dateTools.floorSeconds(modifiedDate) > dateTools.floorSeconds(new Date(ifUnmodifiedSince))) {
return res.status(apiConst.HTTP.PRECONDITION_FAILED).end();
}
await applyPatch(opCtx, identifier, doc, storageDoc);
}
else {
return res.status(apiConst.HTTP.NOT_FOUND).end();
}
}
/**
* Patch existing document in the collection
* @param {Object} opCtx
* @param {string} identifier
* @param {Object} doc - fields and values to patch
* @param {Object} storageDoc - original (database) version of document
*/
async function applyPatch (opCtx, identifier, doc, storageDoc) {
const { ctx, res, col, auth } = opCtx;
if (validate(opCtx, doc, storageDoc) !== true)
return;
const now = new Date;
doc.srvModified = now.getTime();
if (auth && auth.subject && auth.subject.name) {
doc.modifiedBy = auth.subject.name;
}
const matchedCount = await col.storage.updateOne(identifier, doc);
if (!matchedCount)
throw new Error('matchedCount empty');
res.setHeader('Last-Modified', now.toUTCString());
res.status(apiConst.HTTP.NO_CONTENT).send({ });
const fieldsProjector = new FieldsProjector('_all');
const patchedDocs = await col.storage.findOne(identifier, fieldsProjector);
const patchedDoc = patchedDocs[0];
fieldsProjector.applyProjection(patchedDoc);
ctx.bus.emit('storage-socket-update', { colName: col.colName, doc: patchedDoc });
col.autoPrune();
ctx.bus.emit('data-received');
}
function patchOperation (ctx, env, app, col) {
return async function operation (req, res) {
const opCtx = { app, ctx, env, col, req, res };
try {
opCtx.auth = await security.authenticate(opCtx);
await patch(opCtx);
} catch (err) {
console.error(err);
if (!res.headersSent) {
return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
}
}
};
}
module.exports = patchOperation;
+19
View File
@@ -0,0 +1,19 @@
'use strict';
const updateValidate = require('../update/validate')
;
/**
* Validate document to patch
* @param {Object} opCtx
* @param {Object} doc
* @param {Object} storageDoc
* @returns string - null if validation fails
*/
function validate (opCtx, doc, storageDoc) {
return updateValidate(opCtx, doc, storageDoc, { isPatching: true });
}
module.exports = validate;
+75
View File
@@ -0,0 +1,75 @@
'use strict';
const apiConst = require('../../const.json')
, security = require('../../security')
, opTools = require('../../shared/operationTools')
, dateTools = require('../../shared/dateTools')
, FieldsProjector = require('../../shared/fieldsProjector')
;
/**
* READ: Retrieves a single document from the collection
*/
async function read (opCtx) {
const { req, res, col } = opCtx;
await security.demandPermission(opCtx, `api:${col.colName}:read`);
const fieldsProjector = new FieldsProjector(req.query.fields);
const result = await col.storage.findOne(req.params.identifier
, fieldsProjector.storageProjection());
if (!result)
throw new Error('empty result');
if (result.length === 0) {
return res.status(apiConst.HTTP.NOT_FOUND).end();
}
const doc = result[0];
if (doc.isValid === false) {
return res.status(apiConst.HTTP.GONE).end();
}
const modifiedDate = col.resolveDates(doc);
if (modifiedDate) {
res.setHeader('Last-Modified', modifiedDate.toUTCString());
const ifModifiedSince = req.get('If-Modified-Since');
if (ifModifiedSince
&& dateTools.floorSeconds(modifiedDate) <= dateTools.floorSeconds(new Date(ifModifiedSince))) {
return res.status(apiConst.HTTP.NOT_MODIFIED).end();
}
}
fieldsProjector.applyProjection(doc);
res.status(apiConst.HTTP.OK).send(doc);
}
function readOperation (ctx, env, app, col) {
return async function operation (req, res) {
const opCtx = { app, ctx, env, col, req, res };
try {
opCtx.auth = await security.authenticate(opCtx);
await read(opCtx);
} catch (err) {
console.error(err);
if (!res.headersSent) {
return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
}
}
};
}
module.exports = readOperation;
+140
View File
@@ -0,0 +1,140 @@
'use strict';
const apiConst = require('../../const.json')
, dateTools = require('../../shared/dateTools')
, stringTools = require('../../shared/stringTools')
, opTools = require('../../shared/operationTools')
;
const filterRegex = /(.*)\$([a-zA-Z]+)/;
/**
* Parse value of the parameter (to the correct data type)
*/
function parseValue(param, value) {
value = stringTools.isNumberInString(value) ? parseFloat(value) : value; // convert number from string
// convert boolean from string
if (value === 'true')
value = true;
if (value === 'false')
value = false;
// unwrap string in single quotes
if (typeof(value) === 'string' && value.startsWith('\'') && value.endsWith('\'')) {
value = value.substr(1, value.length - 2);
}
if (['date', 'srvModified', 'srvCreated'].includes(param)) {
let m = dateTools.parseToMoment(value);
if (m && m.isValid()) {
value = m.valueOf();
}
}
if (param === 'created_at') {
let m = dateTools.parseToMoment(value);
if (m && m.isValid()) {
value = m.toISOString();
}
}
return value;
}
/**
* Parse filtering criteria from query string
*/
function parseFilter (req, res) {
const filter = []
, reservedParams = ['token', 'sort', 'sort$desc', 'limit', 'skip', 'fields', 'now']
, operators = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 're']
;
for (let param in req.query) {
if (!Object.prototype.hasOwnProperty.call(req.query, param)
|| reservedParams.includes(param)) continue;
let field = param
, operator = 'eq'
;
const match = filterRegex.exec(param);
if (match != null) {
operator = match[2];
field = match[1];
if (!operators.includes(operator)) {
opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST,
apiConst.MSG.HTTP_400_UNSUPPORTED_FILTER_OPERATOR.replace('{0}', operator));
return null;
}
}
const value = parseValue(field, req.query[param]);
filter.push({ field, operator, value });
}
return filter;
}
/**
* Parse sorting from query string
*/
function parseSort (req, res) {
let sort = {}
, sortDirection = 1;
if (req.query.sort && req.query.sort$desc) {
opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_SORT_SORT_DESC);
return null;
}
if (req.query.sort$desc) {
sortDirection = -1;
sort[req.query.sort$desc] = sortDirection;
}
else {
if (req.query.sort) {
sort[req.query.sort] = sortDirection;
}
}
sort.identifier = sortDirection;
sort.created_at = sortDirection;
sort.date = sortDirection;
return sort;
}
/**
* Parse skip (offset) from query string
*/
function parseSkip (req, res) {
let skip = 0;
if (req.query.skip) {
if (!isNaN(req.query.skip) && req.query.skip >= 0) {
skip = parseInt(req.query.skip);
}
else {
opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_SKIP);
return null;
}
}
return skip;
}
module.exports = {
parseFilter,
parseSort,
parseSkip
};
+77
View File
@@ -0,0 +1,77 @@
'use strict';
const apiConst = require('../../const.json')
, security = require('../../security')
, opTools = require('../../shared/operationTools')
, input = require('./input')
, _each = require('lodash/each')
, FieldsProjector = require('../../shared/fieldsProjector')
;
/**
* SEARCH: Search documents from the collection
*/
async function search (opCtx) {
const { req, res, col } = opCtx;
if (col.colName === 'settings') {
await security.demandPermission(opCtx, `api:${col.colName}:admin`);
} else {
await security.demandPermission(opCtx, `api:${col.colName}:read`);
}
const fieldsProjector = new FieldsProjector(req.query.fields);
const filter = input.parseFilter(req, res)
, sort = input.parseSort(req, res)
, limit = col.parseLimit(req, res)
, skip = input.parseSkip(req, res)
, projection = fieldsProjector.storageProjection()
, onlyValid = true
;
if (filter !== null && sort !== null && limit !== null && skip !== null && projection !== null) {
const result = await col.storage.findMany(filter
, sort
, limit
, skip
, projection
, onlyValid);
if (!result)
throw new Error('empty result');
_each(result, col.resolveDates);
_each(result, fieldsProjector.applyProjection);
res.status(apiConst.HTTP.OK).send(result);
}
}
function searchOperation (ctx, env, app, col) {
return async function operation (req, res) {
const opCtx = { app, ctx, env, col, req, res };
try {
opCtx.auth = await security.authenticate(opCtx);
await search(opCtx);
} catch (err) {
console.error(err);
if (!res.headersSent) {
return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
}
}
};
}
module.exports = searchOperation;
+103
View File
@@ -0,0 +1,103 @@
'use strict';
const _ = require('lodash')
, dateTools = require('../shared/dateTools')
, Collection = require('./collection')
;
function fallbackDate (doc) {
const m = dateTools.parseToMoment(doc.date);
return m == null || !m.isValid()
? null
: m.toDate();
}
function fallbackCreatedAt (doc) {
const m = dateTools.parseToMoment(doc.created_at);
return m == null || !m.isValid()
? null
: m.toDate();
}
function setupGenericCollections (ctx, env, app) {
const cols = { }
, enabledCols = app.get('enabledCollections');
if (_.includes(enabledCols, 'devicestatus')) {
cols.devicestatus = new Collection({
ctx, env, app,
colName: 'devicestatus',
storageColName: env.devicestatus_collection || 'devicestatus',
fallbackGetDate: fallbackCreatedAt,
dedupFallbackFields: ['created_at', 'device'],
fallbackDateField: 'created_at'
});
}
const entriesCollection = new Collection({
ctx, env, app,
colName: 'entries',
storageColName: env.entries_collection || 'entries',
fallbackGetDate: fallbackDate,
dedupFallbackFields: ['date', 'type'],
fallbackDateField: 'date'
});
app.set('entriesCollection', entriesCollection);
if (_.includes(enabledCols, 'entries')) {
cols.entries = entriesCollection;
}
if (_.includes(enabledCols, 'food')) {
cols.food = new Collection({
ctx, env, app,
colName: 'food',
storageColName: env.food_collection || 'food',
fallbackGetDate: fallbackCreatedAt,
dedupFallbackFields: ['created_at'],
fallbackDateField: 'created_at'
});
}
if (_.includes(enabledCols, 'profile')) {
cols.profile = new Collection({
ctx, env, app,
colName: 'profile',
storageColName: env.profile_collection || 'profile',
fallbackGetDate: fallbackCreatedAt,
dedupFallbackFields: ['created_at'],
fallbackDateField: 'created_at'
});
}
if (_.includes(enabledCols, 'settings')) {
cols.settings = new Collection({
ctx, env, app,
colName: 'settings',
storageColName: env.settings_collection || 'settings'
});
}
if (_.includes(enabledCols, 'treatments')) {
cols.treatments = new Collection({
ctx, env, app,
colName: 'treatments',
storageColName: env.treatments_collection || 'treatments',
fallbackGetDate: fallbackCreatedAt,
dedupFallbackFields: ['created_at', 'eventType'],
fallbackDateField: 'created_at'
});
}
_.forOwn(cols, function forMember (col) {
col.mapRoutes();
});
app.set('collections', cols);
}
module.exports = setupGenericCollections;
+86
View File
@@ -0,0 +1,86 @@
'use strict';
const _ = require('lodash')
, dateTools = require('../../shared/dateTools')
, apiConst = require('../../const.json')
, security = require('../../security')
, insert = require('../create/insert')
, replace = require('./replace')
, opTools = require('../../shared/operationTools')
;
/**
* UPDATE: Updates a document in the collection
*/
async function update (opCtx) {
const { col, req, res } = opCtx;
const doc = req.body;
if (_.isEmpty(doc)) {
return opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_REQUEST_BODY);
}
col.parseDate(doc);
opTools.resolveIdentifier(doc);
const identifier = req.params.identifier
, identifyingFilter = col.storage.identifyingFilter(identifier);
const result = await col.storage.findOneFilter(identifyingFilter, { });
if (!result)
throw new Error('empty result');
doc.identifier = identifier;
if (result.length > 0) {
await updateConditional(opCtx, doc, result[0]);
}
else {
await insert(opCtx, doc);
}
}
async function updateConditional (opCtx, doc, storageDoc) {
const { col, req, res } = opCtx;
if (storageDoc.isValid === false) {
return res.status(apiConst.HTTP.GONE).end();
}
const modifiedDate = col.resolveDates(storageDoc)
, ifUnmodifiedSince = req.get('If-Unmodified-Since');
if (ifUnmodifiedSince
&& dateTools.floorSeconds(modifiedDate) > dateTools.floorSeconds(new Date(ifUnmodifiedSince))) {
return res.status(apiConst.HTTP.PRECONDITION_FAILED).end();
}
await replace(opCtx, doc, storageDoc);
}
function updateOperation (ctx, env, app, col) {
return async function operation (req, res) {
const opCtx = { app, ctx, env, col, req, res };
try {
opCtx.auth = await security.authenticate(opCtx);
await update(opCtx);
} catch (err) {
console.error(err);
if (!res.headersSent) {
return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
}
}
};
}
module.exports = updateOperation;
+52
View File
@@ -0,0 +1,52 @@
'use strict';
const apiConst = require('../../const.json')
, security = require('../../security')
, validate = require('./validate.js')
;
/**
* Replace existing document in the collection
* @param {Object} opCtx
* @param {any} doc - new version of document to set
* @param {any} storageDoc - old version of document (existing in the storage)
* @param {Object} options
*/
async function replace (opCtx, doc, storageDoc, options) {
const { ctx, auth, col, req, res } = opCtx;
const { isDeduplication } = options || {};
await security.demandPermission(opCtx, `api:${col.colName}:update`);
if (validate(opCtx, doc, storageDoc, { isDeduplication }) !== true)
return;
const now = new Date;
doc.srvModified = now.getTime();
doc.srvCreated = storageDoc.srvCreated || doc.srvModified;
if (auth && auth.subject && auth.subject.name) {
doc.subject = auth.subject.name;
}
const matchedCount = await col.storage.replaceOne(storageDoc.identifier, doc);
if (!matchedCount)
throw new Error('empty matchedCount');
res.setHeader('Last-Modified', now.toUTCString());
if (storageDoc.identifier !== doc.identifier || isDeduplication) {
res.setHeader('Location', `${req.baseUrl}${req.path}/${doc.identifier}`);
}
res.status(apiConst.HTTP.NO_CONTENT).send({ });
ctx.bus.emit('storage-socket-update', { colName: col.colName, doc });
col.autoPrune();
ctx.bus.emit('data-received');
}
module.exports = replace;
+43
View File
@@ -0,0 +1,43 @@
'use strict';
const apiConst = require('../../const.json')
, opTools = require('../../shared/operationTools')
;
/**
* Validation of document to update
* @param {Object} opCtx
* @param {Object} doc
* @param {Object} storageDoc
* @param {Object} options
* @returns string with error message if validation fails, true in case of success
*/
function validate (opCtx, doc, storageDoc, options) {
const { res } = opCtx;
const { isPatching, isDeduplication } = options || {};
const immutable = ['identifier', 'date', 'utcOffset', 'eventType', 'device', 'app',
'srvCreated', 'subject', 'srvModified', 'modifiedBy', 'isValid'];
for (const field of immutable) {
// change of identifier is allowed in deduplication (for APIv1 documents)
if (field === 'identifier' && isDeduplication)
continue;
// changing deleted document is without restrictions
if (storageDoc.isValid === false)
continue;
if (typeof(doc[field]) !== 'undefined' && doc[field] !== storageDoc[field]) {
return opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST,
apiConst.MSG.HTTP_400_IMMUTABLE_FIELD.replace('{0}', field));
}
}
return opTools.validateCommon(doc, res, { isPatching });
}
module.exports = validate;
+106
View File
@@ -0,0 +1,106 @@
'use strict';
const express = require('express')
, bodyParser = require('body-parser')
, StorageSocket = require('./storageSocket')
, apiConst = require('./const.json')
, security = require('./security')
, genericSetup = require('./generic/setup')
, swaggerSetup = require('./swagger')
;
function configure (env, ctx) {
const self = { }
, app = express()
;
self.setENVTruthy = function setENVTruthy (varName, defaultValue) {
//for some reason Azure uses this prefix, maybe there is a good reason
let value = process.env['CUSTOMCONNSTR_' + varName]
|| process.env['CUSTOMCONNSTR_' + varName.toLowerCase()]
|| process.env[varName]
|| process.env[varName.toLowerCase()];
value = value != null ? value : defaultValue;
if (typeof value === 'string' && (value.toLowerCase() === 'on' || value.toLowerCase() === 'true')) { value = true; }
if (typeof value === 'string' && (value.toLowerCase() === 'off' || value.toLowerCase() === 'false')) { value = false; }
app.set(varName, value);
return value;
};
app.setENVTruthy = self.setENVTruthy;
self.setupApiEnvironment = function setupApiEnvironment () {
app.use(bodyParser.json({
limit: 1048576 * 50
}), function errorHandler (err, req, res, next) {
console.error(err);
res.status(500).json({
status: 500,
message: apiConst.MSG.HTTP_500_INTERNAL_ERROR
});
if (next) { // we need 4th parameter next to behave like error handler, but we have to use it to prevent "unused variable" message
}
});
// we don't need these here
app.set('etag', false);
app.set('x-powered-by', false); // this seems to be unreliable
app.use(function (req, res, next) {
res.removeHeader('x-powered-by');
next();
});
app.set('name', env.name);
app.set('version', env.version);
app.set('apiVersion', apiConst.API3_VERSION);
app.set('units', env.DISPLAY_UNITS);
app.set('enabledCollections', ['devicestatus', 'entries', 'food', 'profile', 'settings', 'treatments']);
self.setENVTruthy('API3_SECURITY_ENABLE', apiConst.API3_SECURITY_ENABLE);
self.setENVTruthy('API3_TIME_SKEW_TOLERANCE', apiConst.API3_TIME_SKEW_TOLERANCE);
self.setENVTruthy('API3_DEDUP_FALLBACK_ENABLED', apiConst.API3_DEDUP_FALLBACK_ENABLED);
self.setENVTruthy('API3_CREATED_AT_FALLBACK_ENABLED', apiConst.API3_CREATED_AT_FALLBACK_ENABLED);
self.setENVTruthy('API3_MAX_LIMIT', apiConst.API3_MAX_LIMIT);
};
self.setupApiRoutes = function setupApiRoutes () {
app.get('/version', require('./specific/version')(app, ctx, env));
if (app.get('env') === 'development') { // for development and testing purposes only
app.get('/test', async function test (req, res) {
try {
const opCtx = {app, ctx, env, req, res};
opCtx.auth = await security.authenticate(opCtx);
await security.demandPermission(opCtx, 'api:entries:read');
res.status(200).end();
} catch (error) {
console.error(error);
}
});
}
app.get('/lastModified', require('./specific/lastModified')(app, ctx, env));
app.get('/status', require('./specific/status')(app, ctx, env));
};
self.setupApiEnvironment();
genericSetup(ctx, env, app);
self.setupApiRoutes();
swaggerSetup(app);
ctx.storageSocket = new StorageSocket(app, env, ctx);
return app;
}
module.exports = configure;
+122
View File
@@ -0,0 +1,122 @@
'use strict';
const moment = require('moment')
, apiConst = require('./const.json')
, _ = require('lodash')
, shiroTrie = require('shiro-trie')
, dateTools = require('./shared/dateTools')
, opTools = require('./shared/operationTools')
;
/**
* Check if Date header in HTTP request (or 'now' query parameter) is present and valid (with error response sending)
*/
function checkDateHeader (opCtx) {
const { app, req, res } = opCtx;
let dateString = req.header('Date');
if (!dateString) {
dateString = req.query.now;
}
if (!dateString) {
return opTools.sendJSONStatus(res, apiConst.HTTP.UNAUTHORIZED, apiConst.MSG.HTTP_401_MISSING_DATE);
}
let dateMoment = dateTools.parseToMoment(dateString);
if (!dateMoment) {
return opTools.sendJSONStatus(res, apiConst.HTTP.UNAUTHORIZED, apiConst.MSG.HTTP_401_BAD_DATE);
}
let nowMoment = moment(new Date());
let diffMinutes = moment.duration(nowMoment.diff(dateMoment)).asMinutes();
if (Math.abs(diffMinutes) > app.get('API3_TIME_SKEW_TOLERANCE')) {
return opTools.sendJSONStatus(res, apiConst.HTTP.UNAUTHORIZED, apiConst.MSG.HTTP_401_DATE_OUT_OF_TOLERANCE);
}
return true;
}
function authenticate (opCtx) {
return new Promise(function promise (resolve, reject) {
let { app, ctx, req, res } = opCtx;
if (!app.get('API3_SECURITY_ENABLE')) {
const adminShiro = shiroTrie.new();
adminShiro.add('*');
return resolve({ shiros: [ adminShiro ] });
}
if (req.protocol !== 'https') {
return reject(
opTools.sendJSONStatus(res, apiConst.HTTP.FORBIDDEN, apiConst.MSG.HTTP_403_NOT_USING_HTTPS));
}
const checkDateResult = checkDateHeader(opCtx);
if (checkDateResult !== true) {
return checkDateResult;
}
let token = ctx.authorization.extractToken(req);
if (!token) {
return reject(
opTools.sendJSONStatus(res, apiConst.HTTP.UNAUTHORIZED, apiConst.MSG.HTTP_401_MISSING_OR_BAD_TOKEN));
}
ctx.authorization.resolve({ token }, function resolveFinish (err, result) {
if (err) {
return reject(
opTools.sendJSONStatus(res, apiConst.HTTP.UNAUTHORIZED, apiConst.MSG.HTTP_401_BAD_TOKEN));
}
else {
return resolve(result);
}
});
});
}
/**
* Checks for the permission from the authorization without error response sending
* @param {any} auth
* @param {any} permission
*/
function checkPermission (auth, permission) {
if (auth) {
const found = _.find(auth.shiros, function checkEach (shiro) {
return shiro && shiro.check(permission);
});
return _.isObject(found);
}
else {
return false;
}
}
function demandPermission (opCtx, permission) {
return new Promise(function promise (resolve, reject) {
const { auth, res } = opCtx;
if (checkPermission(auth, permission)) {
return resolve(true);
} else {
return reject(
opTools.sendJSONStatus(res, apiConst.HTTP.FORBIDDEN, apiConst.MSG.HTTP_403_MISSING_PERMISSION.replace('{0}', permission)));
}
});
}
module.exports = {
authenticate,
checkPermission,
demandPermission
};
+78
View File
@@ -0,0 +1,78 @@
'use strict';
const moment = require('moment')
, stringTools = require('./stringTools')
, apiConst = require('../const.json')
;
/**
* Floor date to whole seconds (cut off milliseconds)
* @param {Date} date
*/
function floorSeconds (date) {
let ms = date.getTime();
ms -= ms % 1000;
return new Date(ms);
}
/**
* Parse date as moment object from value or array of values.
* @param {any} value
*/
function parseToMoment (value)
{
if (!value)
return null;
if (Array.isArray(value)) {
for (let item of value) {
let m = parseToMoment(item);
if (m !== null)
return m;
}
}
else {
if (typeof value === 'string' && stringTools.isNumberInString(value)) {
value = parseFloat(value);
}
if (typeof value === 'number') {
let m = moment(value);
if (!m.isValid())
return null;
if (m.valueOf() < apiConst.MIN_TIMESTAMP)
m = moment.unix(m);
if (!m.isValid() || m.valueOf() < apiConst.MIN_TIMESTAMP)
return null;
return m;
}
if (typeof value === 'string') {
let m = moment.parseZone(value, moment.ISO_8601);
if (!m.isValid())
m = moment.parseZone(value, moment.RFC_2822);
if (!m.isValid() || m.valueOf() < apiConst.MIN_TIMESTAMP)
return null;
return m;
}
}
// no parsing option succeeded => failure
return null;
}
module.exports = {
floorSeconds,
parseToMoment
};
+82
View File
@@ -0,0 +1,82 @@
'use strict';
const _each = require('lodash/each');
/**
* Decoder of 'fields' parameter providing storage projections
* @param {string} fieldsString - fields parameter from user
*/
function FieldsProjector (fieldsString) {
const self = this
, exclude = [];
let specific = null;
switch (fieldsString)
{
case '_all':
break;
default:
if (fieldsString) {
specific = fieldsString.split(',');
}
}
const systemFields = ['identifier', 'srvCreated', 'created_at', 'date'];
/**
* Prepare projection definition for storage query
* */
self.storageProjection = function storageProjection () {
const projection = { };
if (specific) {
_each(specific, function include (field) {
projection[field] = 1;
});
_each(systemFields, function include (field) {
projection[field] = 1;
});
}
else {
_each(exclude, function exclude (field) {
projection[field] = 0;
});
_each(exclude, function exclude (field) {
if (systemFields.indexOf(field) >= 0) {
delete projection[field];
}
});
}
return projection;
};
/**
* Cut off unwanted fields from given document
* @param {Object} doc
*/
self.applyProjection = function applyProjection (doc) {
if (specific) {
for(const field in doc) {
if (specific.indexOf(field) === -1) {
delete doc[field];
}
}
}
else {
_each(exclude, function include (field) {
if (typeof(doc[field]) !== 'undefined') {
delete doc[field];
}
});
}
};
}
module.exports = FieldsProjector;
+111
View File
@@ -0,0 +1,111 @@
'use strict';
const apiConst = require('../const.json')
, stringTools = require('./stringTools')
, uuidv5 = require('uuid/v5')
, uuidNamespace = [...Buffer.from("NightscoutRocks!", "ascii")] // official namespace for NS :-)
;
function sendJSONStatus (res, status, title, description, warning) {
const json = {
status: status,
message: title,
description: description
};
// Add optional warning message.
if (warning) { json.warning = warning; }
res.status(status).json(json);
return title;
}
/**
* Validate document's common fields
* @param {Object} doc
* @param {any} res
* @param {Object} options
* @returns {any} - string with error message if validation fails, true in case of success
*/
function validateCommon (doc, res, options) {
const { isPatching } = options || {};
if ((!isPatching || typeof(doc.date) !== 'undefined')
&& (typeof(doc.date) !== 'number'
|| doc.date <= apiConst.MIN_TIMESTAMP)
) {
return sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_FIELD_DATE);
}
if ((!isPatching || typeof(doc.utcOffset) !== 'undefined')
&& (typeof(doc.utcOffset) !== 'number'
|| doc.utcOffset < apiConst.MIN_UTC_OFFSET
|| doc.utcOffset > apiConst.MAX_UTC_OFFSET)
) {
return sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_FIELD_UTC);
}
if ((!isPatching || typeof(doc.app) !== 'undefined')
&& (typeof(doc.app) !== 'string'
|| stringTools.isNullOrWhitespace(doc.app))
) {
return sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_FIELD_APP);
}
return true;
}
/**
* Calculate identifier for the document
* @param {Object} doc
* @returns string
*/
function calculateIdentifier (doc) {
if (!doc)
return undefined;
let key = doc.device + '_' + doc.date;
if (doc.eventType) {
key += '_' + doc.eventType;
}
return uuidv5(key, uuidNamespace);
}
/**
* Validate identifier in the document
* @param {Object} doc
*/
function resolveIdentifier (doc) {
let identifier = calculateIdentifier(doc);
if (doc.identifier) {
if (doc.identifier !== identifier) {
console.warn(`APIv3: Identifier mismatch (expected: ${identifier}, received: ${doc.identifier})`);
console.log(doc);
}
}
else {
doc.identifier = identifier;
}
}
module.exports = {
sendJSONStatus,
validateCommon,
calculateIdentifier,
resolveIdentifier
};
+63
View File
@@ -0,0 +1,63 @@
'use strict';
function getStorageVersion (app) {
return new Promise(function (resolve, reject) {
try {
const storage = app.get('entriesCollection').storage;
let storageVersion = app.get('storageVersion');
if (storageVersion) {
process.nextTick(() => {
resolve(storageVersion);
});
} else {
storage.version()
.then(storageVersion => {
app.set('storageVersion', storageVersion);
resolve(storageVersion);
}, reject);
}
} catch (error) {
reject(error);
}
});
}
function getVersionInfo(app) {
return new Promise(function (resolve, reject) {
try {
const srvDate = new Date()
, info = { version: app.get('version')
, apiVersion: app.get('apiVersion')
, srvDate: srvDate.getTime()
};
getStorageVersion(app)
.then(storageVersion => {
if (!storageVersion)
throw new Error('empty storageVersion');
info.storage = storageVersion;
resolve(info);
}, reject);
} catch(error) {
reject(error);
}
});
}
module.exports = {
getStorageVersion,
getVersionInfo
};
+28
View File
@@ -0,0 +1,28 @@
'use strict';
/**
* Check the string for strictly valid number (no other characters present)
* @param {any} str
*/
function isNumberInString (str) {
return !isNaN(parseFloat(str)) && isFinite(str);
}
/**
* Check the string for non-whitespace characters presence
* @param {any} input
*/
function isNullOrWhitespace (input) {
if (typeof input === 'undefined' || input == null) return true;
return input.replace(/\s/g, '').length < 1;
}
module.exports = {
isNumberInString,
isNullOrWhitespace
};
+101
View File
@@ -0,0 +1,101 @@
'use strict';
function configure (app, ctx, env) {
const express = require('express')
, api = express.Router( )
, apiConst = require('../const.json')
, security = require('../security')
, opTools = require('../shared/operationTools')
;
api.get('/lastModified', async function getLastModified (req, res) {
async function getLastModified (col) {
let result;
const lastModified = await col.storage.getLastModified('srvModified');
if (lastModified) {
result = lastModified.srvModified ? lastModified.srvModified : null;
}
if (col.fallbackDateField) {
const lastModified = await col.storage.getLastModified(col.fallbackDateField);
if (lastModified && lastModified[col.fallbackDateField]) {
let timestamp = lastModified[col.fallbackDateField];
if (typeof(timestamp) === 'string') {
timestamp = (new Date(timestamp)).getTime();
}
if (result === null || result < timestamp) {
result = timestamp;
}
}
}
return { colName: col.colName, lastModified: result };
}
async function collectionsAsync (auth) {
const cols = app.get('collections')
, promises = []
, output = {}
;
for (const colName in cols) {
const col = cols[colName];
if (security.checkPermission(auth, 'api:' + col.colName + ':read')) {
promises.push(getLastModified(col));
}
}
const results = await Promise.all(promises);
for (const result of results) {
if (result.lastModified)
output[result.colName] = result.lastModified;
}
return output;
}
async function operation (opCtx) {
const { res, auth } = opCtx;
const srvDate = new Date();
let info = {
srvDate: srvDate.getTime(),
collections: { }
};
info.collections = await collectionsAsync(auth);
res.json(info);
}
const opCtx = { app, ctx, env, req, res };
try {
opCtx.auth = await security.authenticate(opCtx);
await operation(opCtx);
} catch (err) {
console.error(err);
if (!res.headersSent) {
return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
}
}
});
return api;
}
module.exports = configure;
+71
View File
@@ -0,0 +1,71 @@
'use strict';
function configure (app, ctx, env) {
const express = require('express')
, api = express.Router( )
, apiConst = require('../const.json')
, storageTools = require('../shared/storageTools')
, security = require('../security')
, opTools = require('../shared/operationTools')
;
api.get('/status', async function getStatus (req, res) {
function permsForCol (col, auth) {
let colPerms = '';
if (security.checkPermission(auth, 'api:' + col.colName + ':create')) {
colPerms += 'c';
}
if (security.checkPermission(auth, 'api:' + col.colName + ':read')) {
colPerms += 'r';
}
if (security.checkPermission(auth, 'api:' + col.colName + ':update')) {
colPerms += 'u';
}
if (security.checkPermission(auth, 'api:' + col.colName + ':delete')) {
colPerms += 'd';
}
return colPerms;
}
async function operation (opCtx) {
const cols = app.get('collections');
let info = await storageTools.getVersionInfo(app);
info.apiPermissions = {};
for (let col in cols) {
const colPerms = permsForCol(col, opCtx.auth);
if (colPerms) {
info.apiPermissions[col] = colPerms;
}
}
res.json(info);
}
const opCtx = { app, ctx, env, req, res };
try {
opCtx.auth = await security.authenticate(opCtx);
await operation(opCtx);
} catch (err) {
console.error(err);
if (!res.headersSent) {
return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
}
}
});
return api;
}
module.exports = configure;
+28
View File
@@ -0,0 +1,28 @@
'use strict';
function configure (app) {
const express = require('express')
, api = express.Router( )
, apiConst = require('../const.json')
, storageTools = require('../shared/storageTools')
, opTools = require('../shared/operationTools')
;
api.get('/version', async function getVersion (req, res) {
try {
const versionInfo = await storageTools.getVersionInfo(app);
res.json(versionInfo);
} catch(error) {
console.error(error);
if (!res.headersSent) {
return opTools.sendJSONStatus(res, apiConst.HTTP.INTERNAL_ERROR, apiConst.MSG.STORAGE_ERROR);
}
}
});
return api;
}
module.exports = configure;
+93
View File
@@ -0,0 +1,93 @@
'use strict';
const utils = require('./utils')
, _ = require('lodash')
;
/**
* Find single document by identifier
* @param {Object} col
* @param {string} identifier
* @param {Object} projection
*/
function findOne (col, identifier, projection) {
return new Promise(function (resolve, reject) {
const filter = utils.filterForOne(identifier);
col.find(filter)
.project(projection)
.sort({ identifier: -1 }) // document with identifier first (not the fallback one)
.toArray(function mongoDone (err, result) {
if (err) {
reject(err);
} else {
_.each(result, utils.normalizeDoc);
resolve(result);
}
});
});
}
/**
* Find single document by query filter
* @param {Object} col
* @param {Object} filter specific filter
* @param {Object} projection
*/
function findOneFilter (col, filter, projection) {
return new Promise(function (resolve, reject) {
col.find(filter)
.project(projection)
.sort({ identifier: -1 }) // document with identifier first (not the fallback one)
.toArray(function mongoDone (err, result) {
if (err) {
reject(err);
} else {
_.each(result, utils.normalizeDoc);
resolve(result);
}
});
});
}
/**
* Find many documents matching the filtering criteria
*/
function findMany (col, filterDef, sort, limit, skip, projection, onlyValid, logicalOperator = 'and') {
return new Promise(function (resolve, reject) {
const filter = utils.parseFilter(filterDef, logicalOperator, onlyValid);
col.find(filter)
.sort(sort)
.limit(limit)
.skip(skip)
.project(projection)
.toArray(function mongoDone (err, result) {
if (err) {
reject(err);
} else {
_.each(result, utils.normalizeDoc);
resolve(result);
}
});
});
}
module.exports = {
findOne,
findOneFilter,
findMany
};
+90
View File
@@ -0,0 +1,90 @@
'use strict';
/**
* Storage implementation using mongoDB
* @param {Object} ctx
* @param {Object} env
* @param {string} colName - name of the collection in mongo database
*/
function MongoCollection (ctx, env, colName) {
const self = this
, utils = require('./utils')
, find = require('./find')
, modify = require('./modify')
;
self.colName = colName;
self.col = ctx.store.collection(colName);
ctx.store.ensureIndexes(self.col, [ 'identifier',
'srvModified',
'isValid'
]);
self.identifyingFilter = utils.identifyingFilter;
self.findOne = (...args) => find.findOne(self.col, ...args);
self.findOneFilter = (...args) => find.findOneFilter(self.col, ...args);
self.findMany = (...args) => find.findMany(self.col, ...args);
self.insertOne = (...args) => modify.insertOne(self.col, ...args);
self.replaceOne = (...args) => modify.replaceOne(self.col, ...args);
self.updateOne = (...args) => modify.updateOne(self.col, ...args);
self.deleteOne = (...args) => modify.deleteOne(self.col, ...args);
self.deleteManyOr = (...args) => modify.deleteManyOr(self.col, ...args);
/**
* Get server version
*/
self.version = function version () {
return new Promise(function (resolve, reject) {
ctx.store.db.admin().buildInfo({}, function mongoDone (err, result) {
err
? reject(err)
: resolve({
storage: 'mongodb',
version: result.version
});
});
});
};
/**
* Get timestamp (e.g. srvModified) of the last modified document
*/
self.getLastModified = function getLastModified (fieldName) {
return new Promise(function (resolve, reject) {
self.col.find()
.sort({ [fieldName]: -1 })
.limit(1)
.project({ [fieldName]: 1 })
.toArray(function mongoDone (err, [ result ]) {
err
? reject(err)
: resolve(result);
});
});
}
}
module.exports = MongoCollection;
+123
View File
@@ -0,0 +1,123 @@
'use strict';
const utils = require('./utils');
/**
* Insert single document
* @param {Object} col
* @param {Object} doc
*/
function insertOne (col, doc) {
return new Promise(function (resolve, reject) {
col.insertOne(doc, function mongoDone(err, result) {
if (err) {
reject(err);
} else {
const identifier = doc.identifier || result.insertedId.toString();
delete doc._id;
resolve(identifier);
}
});
});
}
/**
* Replace single document
* @param {Object} col
* @param {string} identifier
* @param {Object} doc
*/
function replaceOne (col, identifier, doc) {
return new Promise(function (resolve, reject) {
const filter = utils.filterForOne(identifier);
col.replaceOne(filter, doc, { }, function mongoDone(err, result) {
if (err) {
reject(err);
} else {
resolve(result.matchedCount);
}
});
});
}
/**
* Update single document by identifier
* @param {Object} col
* @param {string} identifier
* @param {object} setFields
*/
function updateOne (col, identifier, setFields) {
return new Promise(function (resolve, reject) {
const filter = utils.filterForOne(identifier);
col.updateOne(filter, { $set: setFields }, function mongoDone(err, result) {
if (err) {
reject(err);
} else {
resolve({ updated: result.result.nModified });
}
});
});
}
/**
* Permanently remove single document by identifier
* @param {Object} col
* @param {string} identifier
*/
function deleteOne (col, identifier) {
return new Promise(function (resolve, reject) {
const filter = utils.filterForOne(identifier);
col.deleteOne(filter, function mongoDone(err, result) {
if (err) {
reject(err);
} else {
resolve({ deleted: result.result.n });
}
});
});
}
/**
* Permanently remove many documents matching any of filtering criteria
*/
function deleteManyOr (col, filterDef) {
return new Promise(function (resolve, reject) {
const filter = utils.parseFilter(filterDef, 'or');
col.deleteMany(filter, function mongoDone(err, result) {
if (err) {
reject(err);
} else {
resolve({ deleted: result.deletedCount });
}
});
});
}
module.exports = {
insertOne,
replaceOne,
updateOne,
deleteOne,
deleteManyOr
};
+178
View File
@@ -0,0 +1,178 @@
'use strict';
const _ = require('lodash')
, checkForHexRegExp = new RegExp("^[0-9a-fA-F]{24}$")
, ObjectID = require('mongodb').ObjectID
;
/**
* Normalize document (make it mongoDB independent)
* @param {Object} doc - document loaded from mongoDB
*/
function normalizeDoc (doc) {
if (!doc.identifier) {
doc.identifier = doc._id.toString();
}
delete doc._id;
}
/**
* Parse filter definition array into mongoDB filtering object
* @param {any} filterDef
* @param {string} logicalOperator
* @param {bool} onlyValid
*/
function parseFilter (filterDef, logicalOperator, onlyValid) {
let filter = { };
if (!filterDef)
return filter;
if (!_.isArray(filterDef)) {
return filterDef;
}
let clauses = [];
for (const itemDef of filterDef) {
let item;
switch (itemDef.operator) {
case 'eq':
item = itemDef.value;
break;
case 'ne':
item = { $ne: itemDef.value };
break;
case 'gt':
item = { $gt: itemDef.value };
break;
case 'gte':
item = { $gte: itemDef.value };
break;
case 'lt':
item = { $lt: itemDef.value };
break;
case 'lte':
item = { $lte: itemDef.value };
break;
case 'in':
item = { $in: itemDef.value.toString().split('|') };
break;
case 'nin':
item = { $nin: itemDef.value.toString().split('|') };
break;
case 're':
item = { $regex: itemDef.value.toString() };
break;
default:
throw new Error('Unsupported or missing filter operator ' + itemDef.operator);
}
if (logicalOperator === 'or') {
let clause = { };
clause[itemDef.field] = item;
clauses.push(clause);
}
else {
filter[itemDef.field] = item;
}
}
if (logicalOperator === 'or') {
filter = { $or: clauses };
}
if (onlyValid) {
filter.isValid = { $ne: false };
}
return filter;
}
/**
* Create query filter for single document with identifier fallback
* @param {string} identifier
*/
function filterForOne (identifier) {
const filterOpts = [ { identifier } ];
// fallback to "identifier = _id"
if (checkForHexRegExp.test(identifier)) {
filterOpts.push({ _id: ObjectID(identifier) });
}
return { $or: filterOpts };
}
/**
* Create query filter to check whether the document already exists in the storage.
* This function resolves eventual fallback deduplication.
* @param {string} identifier - identifier of document to check its existence in the storage
* @param {Object} doc - document to check its existence in the storage
* @param {Array} dedupFallbackFields - fields that all need to be matched to identify document via fallback deduplication
* @returns {Object} - query filter for mongo or null in case of no identifying possibility
*/
function identifyingFilter (identifier, doc, dedupFallbackFields) {
const filterItems = [];
if (identifier) {
// standard identifier field (APIv3)
filterItems.push({ identifier: identifier });
// fallback to "identifier = _id" (APIv1)
if (checkForHexRegExp.test(identifier)) {
filterItems.push({ identifier: { $exists: false }, _id: ObjectID(identifier) });
}
}
// let's deal with eventual fallback deduplication
if (!_.isEmpty(doc) && _.isArray(dedupFallbackFields) && dedupFallbackFields.length > 0) {
let dedupFilterItems = [];
_.each(dedupFallbackFields, function addDedupField (field) {
if (doc[field] !== undefined) {
let dedupFilterItem = { };
dedupFilterItem[field] = doc[field];
dedupFilterItems.push(dedupFilterItem);
}
});
if (dedupFilterItems.length === dedupFallbackFields.length) { // all dedup fields are present
dedupFilterItems.push({ identifier: { $exists: false } }); // force not existing identifier for fallback deduplication
filterItems.push({ $and: dedupFilterItems });
}
}
if (filterItems.length > 0)
return { $or: filterItems };
else
return null; // we don't have any filtering rule to identify the document in the storage
}
module.exports = {
normalizeDoc,
parseFilter,
filterForOne,
identifyingFilter
};
+145
View File
@@ -0,0 +1,145 @@
'use strict';
const apiConst = require('./const');
/**
* Socket.IO broadcaster of any storage change
*/
function StorageSocket (app, env, ctx) {
const self = this;
const LOG_GREEN = '\x1B[32m'
, LOG_MAGENTA = '\x1B[35m'
, LOG_RESET = '\x1B[0m'
, LOG = LOG_GREEN + 'STORAGE SOCKET: ' + LOG_RESET
, LOG_ERROR = LOG_MAGENTA + 'STORAGE SOCKET: ' + LOG_RESET
, NAMESPACE = '/storage'
;
/**
* Initialize socket namespace and bind the events
* @param {Object} io Socket.IO object to multiplex namespaces
*/
self.init = function init (io) {
self.io = io;
self.namespace = io.of(NAMESPACE);
self.namespace.on('connection', function onConnected (socket) {
const remoteIP = socket.request.headers['x-forwarded-for'] || socket.request.connection.remoteAddress;
console.log(LOG + 'Connection from client ID: ', socket.client.id, ' IP: ', remoteIP);
socket.on('disconnect', function onDisconnect () {
console.log(LOG + 'Disconnected client ID: ', socket.client.id);
});
socket.on('subscribe', function onSubscribe (message, returnCallback) {
self.subscribe(socket, message, returnCallback);
});
});
ctx.bus.on('storage-socket-create', self.emitCreate);
ctx.bus.on('storage-socket-update', self.emitUpdate);
ctx.bus.on('storage-socket-delete', self.emitDelete);
};
/**
* Authorize Socket.IO client and subscribe him to authorized rooms
* @param {Object} socket
* @param {Object} message input message from the client
* @param {Function} returnCallback function for returning a value back to the client
*/
self.subscribe = function subscribe (socket, message, returnCallback) {
const shouldCallBack = typeof(returnCallback) === 'function';
if (message && message.accessToken) {
return ctx.authorization.resolveAccessToken(message.accessToken, function resolveFinish (err, auth) {
if (err) {
console.log(`${LOG_ERROR} Authorization failed for accessToken:`, message.accessToken);
if (shouldCallBack) {
returnCallback({ success: false, message: apiConst.MSG.SOCKET_MISSING_OR_BAD_ACCESS_TOKEN });
}
return err;
}
else {
return self.subscribeAuthorized(socket, message, auth, returnCallback);
}
});
}
console.log(`${LOG_ERROR} Authorization failed for message:`, message);
if (shouldCallBack) {
returnCallback({ success: false, message: apiConst.MSG.SOCKET_MISSING_OR_BAD_ACCESS_TOKEN});
}
};
/**
* Subscribe already authorized Socket.IO client to his rooms
* @param {Object} socket
* @param {Object} message input message from the client
* @param {Object} auth authorization of the client
* @param {Function} returnCallback function for returning a value back to the client
*/
self.subscribeAuthorized = function subscribeAuthorized (socket, message, auth, returnCallback) {
const shouldCallBack = typeof(returnCallback) === 'function';
const enabledCols = app.get('enabledCollections');
const cols = Array.isArray(message.collections) ? message.collections : enabledCols;
const subscribed = [];
for (const col of cols) {
if (enabledCols.includes(col)) {
const permission = (col === 'settings') ? `api:${col}:admin` : `api:${col}:read`;
if (ctx.authorization.checkMultiple(permission, auth.shiros)) {
socket.join(col);
subscribed.push(col);
}
}
}
const doc = subscribed.length > 0
? { success: true, collections: subscribed }
: { success: false, message: apiConst.MSG.SOCKET_UNAUTHORIZED_TO_ANY };
if (shouldCallBack) {
returnCallback(doc);
}
return doc;
};
/**
* Emit create event to the subscribers (of the collection's room)
* @param {Object} event
*/
self.emitCreate = function emitCreate (event) {
self.namespace.to(event.colName)
.emit('create', event);
};
/**
* Emit update event to the subscribers (of the collection's room)
* @param {Object} event
*/
self.emitUpdate = function emitUpdate (event) {
self.namespace.to(event.colName)
.emit('update', event);
};
/**
* Emit delete event to the subscribers (of the collection's room)
* @param {Object} event
*/
self.emitDelete = function emitDelete (event) {
self.namespace.to(event.colName)
.emit('delete', event);
}
}
module.exports = StorageSocket;
+41
View File
@@ -0,0 +1,41 @@
'use strict';
const express = require('express')
, fs = require('fs')
;
function setupSwaggerUI (app) {
const serveSwaggerDef = function serveSwaggerDef (req, res) {
res.sendFile(__dirname + '/swagger.yaml');
};
app.get('/swagger.yaml', serveSwaggerDef);
const swaggerUiAssetPath = require('swagger-ui-dist').getAbsoluteFSPath();
const swaggerFiles = express.static(swaggerUiAssetPath);
const urlRegex = /url: "[^"]*",/;
const patchIndex = function patchIndex (req, res) {
const indexContent = fs.readFileSync(`${swaggerUiAssetPath}/index.html`)
.toString()
.replace(urlRegex, 'url: "../swagger.yaml",');
res.send(indexContent);
};
app.get('/swagger-ui-dist', function getSwaggerRoot (req, res) {
let targetUrl = req.originalUrl;
if (!targetUrl.endsWith('/')) {
targetUrl += '/';
}
targetUrl += 'index.html';
res.redirect(targetUrl);
});
app.get('/swagger-ui-dist/index.html', patchIndex);
app.use('/swagger-ui-dist', swaggerFiles);
}
module.exports = setupSwaggerUI;
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -55,6 +55,8 @@ function init (env, ctx) {
return token;
}
authorization.extractToken = extractToken;
function authorizeAccessToken (req) {
var accessToken = req.query.token;
@@ -152,9 +154,7 @@ function init (env, ctx) {
if (err) {
return callback(err, { shiros: [ ] });
} else {
var resolved = storage.resolveSubjectAndPermissions(verified.accessToken);
var shiros = resolved.shiros.concat(defaultShiros);
return callback(null, { shiros: shiros, subject: resolved.subject });
authorization.resolveAccessToken (verified.accessToken, callback, defaultShiros);
}
});
} else {
@@ -163,6 +163,21 @@ function init (env, ctx) {
};
authorization.resolveAccessToken = function resolveAccessToken (accessToken, callback, defaultShiros) {
if (!defaultShiros) {
defaultShiros = storage.rolesToShiros(defaultRoles);
}
let resolved = storage.resolveSubjectAndPermissions(accessToken);
if (!resolved || !resolved.subject) {
return callback('Subject not found', null);
}
let shiros = resolved.shiros.concat(defaultShiros);
return callback(null, { shiros: shiros, subject: resolved.subject });
};
authorization.isPermitted = function isPermitted (permission, opts) {
+1 -1
View File
@@ -115,7 +115,7 @@ function boot (env, language) {
} else {
//TODO assume mongo for now, when there are more storage options add a lookup
require('../storage/mongo-storage')(env, function ready(err, store) {
// FIXME, error is always null, if there is an error, the storage.js will throw an exception
// FIXME, error is always null, if there is an error, the index.js will throw an exception
console.log('Mongo Storage system ready');
ctx.store = store;
+4
View File
@@ -520,6 +520,10 @@ function init (env, ctx, server) {
start( );
listeners( );
if (ctx.storageSocket) {
ctx.storageSocket.init(io);
}
return websocket();
}
+1
View File
@@ -109,6 +109,7 @@
"swagger-ui-express": "^4.1.2",
"terser": "^3.17.0",
"traverse": "^0.6.6",
"uuid": "^3.3.2",
"webpack": "^4.39.2",
"webpack-cli": "^3.3.7"
},
+1
View File
@@ -56,6 +56,7 @@ describe('Devicestatus API', function ( ) {
request(self.app)
.get('/api/devicestatus/')
.query('find[created_at][$gte]=2018-12-16')
.query('find[created_at][$lte]=2018-12-17')
.set('api-secret', self.env.api_secret || '')
.expect(200)
.expect(function (response) {
+49
View File
@@ -0,0 +1,49 @@
'use strict';
const request = require('supertest');
require('should');
describe('Basic REST API3', function() {
const self = this
, testConst = require('./fixtures/api3/const.json')
, instance = require('./fixtures/api3/instance')
;
this.timeout(15000);
before(async () => {
self.instance = await instance.create({});
self.app = self.instance.app;
self.env = self.instance.env;
});
after(function after () {
self.instance.server.close();
});
it('GET /swagger', async () => {
let res = await request(self.app)
.get('/api/v3/swagger.yaml')
.expect(200);
res.header['content-length'].should.be.above(0);
});
it('GET /version', async () => {
let res = await request(self.app)
.get('/api/v3/version')
.expect(200);
const apiConst = require('../lib/api3/const.json')
, software = require('../package.json');
res.body.version.should.equal(software.version);
res.body.apiVersion.should.equal(apiConst.API3_VERSION);
res.body.srvDate.should.be.within(testConst.YEAR_2019, testConst.YEAR_2050);
});
});
+487
View File
@@ -0,0 +1,487 @@
/* eslint require-atomic-updates: 0 */
/* global should */
'use strict';
require('should');
describe('API3 CREATE', function() {
const self = this
, testConst = require('./fixtures/api3/const.json')
, instance = require('./fixtures/api3/instance')
, authSubject = require('./fixtures/api3/authSubject')
, opTools = require('../lib/api3/shared/operationTools')
, utils = require('./fixtures/api3/utils')
;
self.validDoc = {
date: (new Date()).getTime(),
app: testConst.TEST_APP,
device: testConst.TEST_DEVICE,
eventType: 'Correction Bolus',
insulin: 0.3
};
self.validDoc.identifier = opTools.calculateIdentifier(self.validDoc);
self.timeout(20000);
/**
* Cleanup after successful creation
*/
self.delete = async function deletePermanent (identifier) {
await self.instance.delete(`${self.url}/${identifier}?permanent=true&token=${self.token.delete}`)
.expect(204);
};
/**
* Get document detail for futher processing
*/
self.get = async function get (identifier) {
let res = await self.instance.get(`${self.url}/${identifier}?token=${self.token.read}`)
.expect(200);
return res.body;
};
/**
* Get document detail for futher processing
*/
self.search = async function search (date) {
let res = await self.instance.get(`${self.url}?date$eq=${date}&token=${self.token.read}`)
.expect(200);
return res.body;
};
before(async () => {
self.instance = await instance.create({});
self.app = self.instance.app;
self.env = self.instance.env;
self.url = '/api/v3/treatments';
let authResult = await authSubject(self.instance.ctx.authorization.storage);
self.subject = authResult.subject;
self.token = authResult.token;
self.urlToken = `${self.url}?token=${self.token.create}`;
});
after(() => {
self.instance.server.close();
});
it('should require authentication', async () => {
let res = await self.instance.post(`${self.url}`)
.send(self.validDoc)
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal('Missing or bad access token or JWT');
});
it('should not found not existing collection', async () => {
let res = await self.instance.post(`/api/v3/NOT_EXIST?token=${self.url}`)
.send(self.validDoc)
.expect(404);
res.body.should.be.empty();
});
it('should require create permission', async () => {
let res = await self.instance.post(`${self.url}?token=${self.token.read}`)
.send(self.validDoc)
.expect(403);
res.body.status.should.equal(403);
res.body.message.should.equal('Missing permission api:treatments:create');
});
it('should reject empty body', async () => {
await self.instance.post(self.urlToken)
.send({ })
.expect(400);
});
it('should accept valid document', async () => {
let res = await self.instance.post(self.urlToken)
.send(self.validDoc)
.expect(201);
res.body.should.be.empty();
res.headers.location.should.equal(`${self.url}/${self.validDoc.identifier}`);
const lastModified = new Date(res.headers['last-modified']).getTime(); // Last-Modified has trimmed milliseconds
let body = await self.get(self.validDoc.identifier);
body.should.containEql(self.validDoc);
const ms = body.srvModified % 1000;
(body.srvModified - ms).should.equal(lastModified);
(body.srvCreated - ms).should.equal(lastModified);
body.subject.should.equal(self.subject.apiCreate.name);
await self.delete(self.validDoc.identifier);
});
it('should reject missing date', async () => {
let doc = Object.assign({}, self.validDoc);
delete doc.date;
let res = await self.instance.post(self.urlToken)
.send(doc)
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Bad or missing date field');
});
it('should reject invalid date null', async () => {
let res = await self.instance.post(self.urlToken)
.send(Object.assign({}, self.validDoc, { date: null }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Bad or missing date field');
});
it('should reject invalid date ABC', async () => {
let res = await self.instance.post(self.urlToken)
.send(Object.assign({}, self.validDoc, { date: 'ABC' }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Bad or missing date field');
});
it('should reject invalid date -1', async () => {
let res = await self.instance.post(self.urlToken)
.send(Object.assign({}, self.validDoc, { date: -1 }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Bad or missing date field');
});
it('should reject invalid date 1 (too old)', async () => {
let res = await self.instance.post(self.urlToken)
.send(Object.assign({}, self.validDoc, { date: 1 }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Bad or missing date field');
});
it('should reject invalid date - illegal format', async () => {
let res = await self.instance.post(self.urlToken)
.send(Object.assign({}, self.validDoc, { date: '2019-20-60T50:90:90' }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Bad or missing date field');
});
it('should reject invalid utcOffset -5000', async () => {
let res = await self.instance.post(self.urlToken)
.send(Object.assign({}, self.validDoc, { utcOffset: -5000 }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Bad or missing utcOffset field');
});
it('should reject invalid utcOffset ABC', async () => {
let res = await self.instance.post(self.urlToken)
.send(Object.assign({}, self.validDoc, { utcOffset: 'ABC' }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Bad or missing utcOffset field');
});
it('should accept valid utcOffset', async () => {
await self.instance.post(self.urlToken)
.send(Object.assign({}, self.validDoc, { utcOffset: 120 }))
.expect(201);
let body = await self.get(self.validDoc.identifier);
body.utcOffset.should.equal(120);
await self.delete(self.validDoc.identifier);
});
it('should reject invalid utcOffset null', async () => {
let res = await self.instance.post(self.urlToken)
.send(Object.assign({}, self.validDoc, { utcOffset: null }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Bad or missing utcOffset field');
});
it('should reject missing app', async () => {
let doc = Object.assign({}, self.validDoc);
delete doc.app;
let res = await self.instance.post(self.urlToken)
.send(doc)
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Bad or missing app field');
});
it('should reject invalid app null', async () => {
let res = await self.instance.post(self.urlToken)
.send(Object.assign({}, self.validDoc, { app: null }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Bad or missing app field');
});
it('should reject empty app', async () => {
let res = await self.instance.post(self.urlToken)
.send(Object.assign({}, self.validDoc, { app: '' }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Bad or missing app field');
});
it('should normalize date and store utcOffset', async () => {
await self.instance.post(self.urlToken)
.send(Object.assign({}, self.validDoc, { date: '2019-06-10T08:07:08,576+02:00' }))
.expect(201);
let body = await self.get(self.validDoc.identifier);
body.date.should.equal(1560146828576);
body.utcOffset.should.equal(120);
await self.delete(self.validDoc.identifier);
});
it('should require update permission for deduplication', async () => {
self.validDoc.date = (new Date()).getTime();
self.validDoc.identifier = utils.randomString('32', 'aA#');
const doc = Object.assign({}, self.validDoc);
await self.instance.post(self.urlToken)
.send(doc)
.expect(201);
let createdBody = await self.get(doc.identifier);
createdBody.should.containEql(doc);
const doc2 = Object.assign({}, doc);
let res = await self.instance.post(self.urlToken)
.send(doc2)
.expect(403);
res.body.status.should.equal(403);
res.body.message.should.equal('Missing permission api:treatments:update');
await self.delete(doc.identifier);
});
it('should deduplicate document by identifier', async () => {
self.validDoc.date = (new Date()).getTime();
self.validDoc.identifier = utils.randomString('32', 'aA#');
const doc = Object.assign({}, self.validDoc);
await self.instance.post(self.urlToken)
.send(doc)
.expect(201);
let createdBody = await self.get(doc.identifier);
createdBody.should.containEql(doc);
const doc2 = Object.assign({}, doc, {
insulin: 0.5
});
await self.instance.post(`${self.url}?token=${self.token.all}`)
.send(doc2)
.expect(204);
let updatedBody = await self.get(doc2.identifier);
updatedBody.should.containEql(doc2);
await self.delete(doc2.identifier);
});
it('should deduplicate document by created_at+eventType', async () => {
self.validDoc.date = (new Date()).getTime();
self.validDoc.identifier = utils.randomString('32', 'aA#');
const doc = Object.assign({}, self.validDoc, {
created_at: new Date(self.validDoc.date).toISOString()
});
delete doc.identifier;
self.instance.ctx.treatments.create([doc], async (err) => { // let's insert the document in APIv1's way
should.not.exist(err);
const doc2 = Object.assign({}, doc, {
insulin: 0.4,
identifier: utils.randomString('32', 'aA#')
});
delete doc2._id; // APIv1 updates input document, we must get rid of _id for the next round
await self.instance.post(`${self.url}?token=${self.token.all}`)
.send(doc2)
.expect(204);
let updatedBody = await self.get(doc2.identifier);
updatedBody.should.containEql(doc2);
await self.delete(doc2.identifier);
});
});
it('should not deduplicate treatment only by created_at', async () => {
self.validDoc.date = (new Date()).getTime();
self.validDoc.identifier = utils.randomString('32', 'aA#');
const doc = Object.assign({}, self.validDoc, {
created_at: new Date(self.validDoc.date).toISOString()
});
delete doc.identifier;
self.instance.ctx.treatments.create([doc], async (err) => { // let's insert the document in APIv1's way
should.not.exist(err);
let oldBody = await self.get(doc._id);
delete doc._id; // APIv1 updates input document, we must get rid of _id for the next round
oldBody.should.containEql(doc);
const doc2 = Object.assign({}, doc, {
eventType: 'Meal Bolus',
insulin: 0.4,
identifier: utils.randomString('32', 'aA#')
});
await self.instance.post(`${self.url}?token=${self.token.all}`)
.send(doc2)
.expect(201);
let updatedBody = await self.get(doc2.identifier);
updatedBody.should.containEql(doc2);
updatedBody.identifier.should.not.equal(oldBody.identifier);
await self.delete(doc2.identifier);
await self.delete(oldBody.identifier);
});
});
it('should overwrite deleted document', async () => {
const date1 = new Date()
, identifier = utils.randomString('32', 'aA#');
await self.instance.post(self.urlToken)
.send(Object.assign({}, self.validDoc, { identifier, date: date1.toISOString() }))
.expect(201);
await self.instance.delete(`${self.url}/${identifier}?token=${self.token.delete}`)
.expect(204);
const date2 = new Date();
let res = await self.instance.post(self.urlToken)
.send(Object.assign({}, self.validDoc, { identifier, date: date2.toISOString() }))
.expect(403);
res.body.status.should.be.equal(403);
res.body.message.should.be.equal('Missing permission api:treatments:update');
res = await self.instance.post(`${self.url}?token=${self.token.all}`)
.send(Object.assign({}, self.validDoc, { identifier, date: date2.toISOString() }))
.expect(204);
res.body.should.be.empty();
let body = await self.get(identifier);
body.date.should.equal(date2.getTime());
body.identifier.should.equal(identifier);
await self.delete(identifier);
});
it('should calculate the identifier', async () => {
self.validDoc.date = (new Date()).getTime();
delete self.validDoc.identifier;
const validIdentifier = opTools.calculateIdentifier(self.validDoc);
let res = await self.instance.post(self.urlToken)
.send(self.validDoc)
.expect(201);
res.body.should.be.empty();
res.headers.location.should.equal(`${self.url}/${validIdentifier}`);
self.validDoc.identifier = validIdentifier;
let body = await self.get(validIdentifier);
body.should.containEql(self.validDoc);
await self.delete(validIdentifier);
});
it('should deduplicate by identifier calculation', async () => {
self.validDoc.date = (new Date()).getTime();
delete self.validDoc.identifier;
const validIdentifier = opTools.calculateIdentifier(self.validDoc);
let res = await self.instance.post(self.urlToken)
.send(self.validDoc)
.expect(201);
res.body.should.be.empty();
res.headers.location.should.equal(`${self.url}/${validIdentifier}`);
self.validDoc.identifier = validIdentifier;
let body = await self.get(validIdentifier);
body.should.containEql(self.validDoc);
delete self.validDoc.identifier;
res = await self.instance.post(`${self.url}?token=${self.token.update}`)
.send(self.validDoc)
.expect(204);
res.body.should.be.empty();
res.headers.location.should.equal(`${self.url}/${validIdentifier}`);
self.validDoc.identifier = validIdentifier;
body = await self.search(self.validDoc.date);
body.length.should.equal(1);
await self.delete(validIdentifier);
});
});
+53
View File
@@ -0,0 +1,53 @@
/* eslint require-atomic-updates: 0 */
'use strict';
require('should');
describe('API3 UPDATE', function() {
const self = this
, instance = require('./fixtures/api3/instance')
, authSubject = require('./fixtures/api3/authSubject')
;
self.timeout(15000);
before(async () => {
self.instance = await instance.create({});
self.app = self.instance.app;
self.env = self.instance.env;
self.url = '/api/v3/treatments';
let authResult = await authSubject(self.instance.ctx.authorization.storage);
self.subject = authResult.subject;
self.token = authResult.token;
self.urlToken = `${self.url}?token=${self.token.delete}`;
});
after(() => {
self.instance.server.close();
});
it('should require authentication', async () => {
let res = await self.instance.delete(`${self.url}/FAKE_IDENTIFIER`)
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal('Missing or bad access token or JWT');
});
it('should not found not existing collection', async () => {
let res = await self.instance.delete(`/api/v3/NOT_EXIST?token=${self.url}`)
.send(self.validDoc)
.expect(404);
res.body.should.be.empty();
});
});
+257
View File
@@ -0,0 +1,257 @@
/* eslint require-atomic-updates: 0 */
'use strict';
require('should');
describe('Generic REST API3', function() {
const self = this
, testConst = require('./fixtures/api3/const.json')
, instance = require('./fixtures/api3/instance')
, authSubject = require('./fixtures/api3/authSubject')
, opTools = require('../lib/api3/shared/operationTools')
, utils = require('./fixtures/api3/utils')
;
utils.randomString('32', 'aA#'); // let's have a brand new identifier for your testing document
self.urlLastModified = '/api/v3/lastModified';
self.historyTimestamp = 0;
self.docOriginal = {
eventType: 'Correction Bolus',
insulin: 1,
date: (new Date()).getTime(),
app: testConst.TEST_APP,
device: testConst.TEST_DEVICE
};
self.identifier = opTools.calculateIdentifier(self.docOriginal);
self.docOriginal.identifier = self.identifier;
this.timeout(30000);
before(async () => {
self.instance = await instance.create({});
self.app = self.instance.app;
self.env = self.instance.env;
self.urlCol = '/api/v3/treatments';
self.urlResource = self.urlCol + '/' + self.identifier;
self.urlHistory = self.urlCol + '/history';
let authResult = await authSubject(self.instance.ctx.authorization.storage);
self.subject = authResult.subject;
self.token = authResult.token;
self.urlToken = `${self.url}?token=${self.token.create}`;
});
after(() => {
self.instance.server.close();
});
self.checkHistoryExistence = async function checkHistoryExistence (assertions) {
let res = await self.instance.get(`${self.urlHistory}/${self.historyTimestamp}?token=${self.token.read}`)
.expect(200);
res.body.length.should.be.above(0);
res.body.should.matchAny(value => {
value.identifier.should.be.eql(self.identifier);
value.srvModified.should.be.above(self.historyTimestamp);
if (typeof(assertions) === 'function') {
assertions(value);
}
self.historyTimestamp = value.srvModified;
});
};
it('LAST MODIFIED to get actual server timestamp', async () => {
let res = await self.instance.get(`${self.urlLastModified}?token=${self.token.read}`)
.expect(200);
self.historyTimestamp = res.body.collections.treatments;
if (!self.historyTimestamp) {
self.historyTimestamp = res.body.srvDate - (10 * 60 * 1000);
}
self.historyTimestamp.should.be.aboveOrEqual(testConst.YEAR_2019);
});
it('STATUS to get actual server timestamp', async () => {
let res = await self.instance.get(`/api/v3/status?token=${self.token.read}`)
.expect(200);
self.historyTimestamp = res.body.srvDate;
self.historyTimestamp.should.be.aboveOrEqual(testConst.YEAR_2019);
});
it('READ of not existing document is not found', async () => {
await self.instance.get(`${self.urlResource}?token=${self.token.read}`)
.expect(404);
});
it('SEARCH of not existing document (not found)', async () => {
let res = await self.instance.get(`${self.urlCol}?token=${self.token.read}`)
.query({ 'identifier_eq': self.identifier })
.expect(200);
res.body.should.have.length(0);
});
it('DELETE of not existing document is not found', async () => {
await self.instance.delete(`${self.urlResource}?token=${self.token.delete}`)
.expect(404);
});
it('CREATE new document', async () => {
await self.instance.post(`${self.urlCol}?token=${self.token.create}`)
.send(self.docOriginal)
.expect(201);
});
it('READ existing document', async () => {
let res = await self.instance.get(`${self.urlResource}?token=${self.token.read}`)
.expect(200);
res.body.should.containEql(self.docOriginal);
self.docActual = res.body;
if (self.historyTimestamp >= self.docActual.srvModified) {
self.historyTimestamp = self.docActual.srvModified - 1;
}
});
it('SEARCH existing document (found)', async () => {
let res = await self.instance.get(`${self.urlCol}?token=${self.token.read}`)
.query({ 'identifier$eq': self.identifier })
.expect(200);
res.body.length.should.be.above(0);
res.body.should.matchAny(value => {
value.identifier.should.be.eql(self.identifier);
});
});
it('new document in HISTORY', async () => {
await self.checkHistoryExistence();
});
it('UPDATE document', async () => {
self.docActual.insulin = 0.5;
await self.instance.put(`${self.urlResource}?token=${self.token.update}`)
.send(self.docActual)
.expect(204);
self.docActual.subject = self.subject.apiUpdate.name;
});
it('document changed in HISTORY', async () => {
await self.checkHistoryExistence();
});
it('document changed in READ', async () => {
let res = await self.instance.get(`${self.urlResource}?token=${self.token.read}`)
.expect(200);
delete self.docActual.srvModified;
res.body.should.containEql(self.docActual);
self.docActual = res.body;
});
it('PATCH document', async () => {
self.docActual.carbs = 5;
self.docActual.insulin = 0.4;
await self.instance.patch(`${self.urlResource}?token=${self.token.update}`)
.send({ 'carbs': self.docActual.carbs, 'insulin': self.docActual.insulin })
.expect(204);
});
it('document changed in HISTORY', async () => {
await self.checkHistoryExistence();
});
it('document changed in READ', async () => {
let res = await self.instance.get(`${self.urlResource}?token=${self.token.read}`)
.expect(200);
delete self.docActual.srvModified;
res.body.should.containEql(self.docActual);
self.docActual = res.body;
});
it('soft DELETE', async () => {
await self.instance.delete(`${self.urlResource}?token=${self.token.delete}`)
.expect(204);
});
it('READ of deleted is gone', async () => {
await self.instance.get(`${self.urlResource}?token=${self.token.read}`)
.expect(410);
});
it('SEARCH of deleted document missing it', async () => {
let res = await self.instance.get(`${self.urlCol}?token=${self.token.read}`)
.query({ 'identifier_eq': self.identifier })
.expect(200);
res.body.should.have.length(0);
});
it('document deleted in HISTORY', async () => {
await self.checkHistoryExistence(value => {
value.isValid.should.be.eql(false);
});
});
it('permanent DELETE', async () => {
await self.instance.delete(`${self.urlResource}?token=${self.token.delete}`)
.query({ 'permanent': 'true' })
.expect(204);
});
it('READ of permanently deleted is not found', async () => {
await self.instance.get(`${self.urlResource}?token=${self.token.read}`)
.expect(404);
});
it('document permanently deleted not in HISTORY', async () => {
let res = await self.instance.get(`${self.urlHistory}/${self.historyTimestamp}?token=${self.token.read}`);
if (res.status === 200) {
res.body.should.matchEach(value => {
value.identifier.should.not.be.eql(self.identifier);
});
} else {
res.status.should.equal(204);
}
});
});
+219
View File
@@ -0,0 +1,219 @@
/* eslint require-atomic-updates: 0 */
'use strict';
require('should');
describe('API3 PATCH', function() {
const self = this
, testConst = require('./fixtures/api3/const.json')
, instance = require('./fixtures/api3/instance')
, authSubject = require('./fixtures/api3/authSubject')
, opTools = require('../lib/api3/shared/operationTools')
;
self.validDoc = {
date: (new Date()).getTime(),
utcOffset: -180,
app: testConst.TEST_APP,
device: testConst.TEST_DEVICE,
eventType: 'Correction Bolus',
insulin: 0.3
};
self.validDoc.identifier = opTools.calculateIdentifier(self.validDoc);
self.timeout(15000);
/**
* Get document detail for futher processing
*/
self.get = async function get (identifier) {
let res = await self.instance.get(`${self.url}/${identifier}?token=${self.token.read}`)
.expect(200);
return res.body;
};
before(async () => {
self.instance = await instance.create({});
self.app = self.instance.app;
self.env = self.instance.env;
self.url = '/api/v3/treatments';
let authResult = await authSubject(self.instance.ctx.authorization.storage);
self.subject = authResult.subject;
self.token = authResult.token;
self.urlToken = `${self.url}/${self.validDoc.identifier}?token=${self.token.update}`;
});
after(() => {
self.instance.server.close();
});
it('should require authentication', async () => {
let res = await self.instance.patch(`${self.url}/FAKE_IDENTIFIER`)
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal('Missing or bad access token or JWT');
});
it('should not found not existing collection', async () => {
let res = await self.instance.patch(`/api/v3/NOT_EXIST?token=${self.url}`)
.send(self.validDoc)
.expect(404);
res.body.should.be.empty();
});
it('should not found not existing document', async () => {
let res = await self.instance.patch(self.urlToken)
.send(self.validDoc)
.expect(404);
res.body.should.be.empty();
// now let's insert the document for further patching
res = await self.instance.post(`${self.url}?token=${self.token.create}`)
.send(self.validDoc)
.expect(201);
res.body.should.be.empty();
});
it('should reject identifier alteration', async () => {
let res = await self.instance.patch(self.urlToken)
.send(Object.assign({}, self.validDoc, { identifier: 'MODIFIED'}))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field identifier cannot be modified by the client');
});
it('should reject date alteration', async () => {
let res = await self.instance.patch(self.urlToken)
.send(Object.assign({}, self.validDoc, { date: self.validDoc.date + 10000 }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field date cannot be modified by the client');
});
it('should reject utcOffset alteration', async () => {
let res = await self.instance.patch(self.urlToken)
.send(Object.assign({}, self.validDoc, { utcOffset: self.utcOffset - 120 }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field utcOffset cannot be modified by the client');
});
it('should reject eventType alteration', async () => {
let res = await self.instance.patch(self.urlToken)
.send(Object.assign({}, self.validDoc, { eventType: 'MODIFIED' }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field eventType cannot be modified by the client');
});
it('should reject device alteration', async () => {
let res = await self.instance.patch(self.urlToken)
.send(Object.assign({}, self.validDoc, { device: 'MODIFIED' }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field device cannot be modified by the client');
});
it('should reject app alteration', async () => {
let res = await self.instance.patch(self.urlToken)
.send(Object.assign({}, self.validDoc, { app: 'MODIFIED' }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field app cannot be modified by the client');
});
it('should reject srvCreated alteration', async () => {
let res = await self.instance.patch(self.urlToken)
.send(Object.assign({}, self.validDoc, { srvCreated: self.validDoc.date - 10000 }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field srvCreated cannot be modified by the client');
});
it('should reject subject alteration', async () => {
let res = await self.instance.patch(self.urlToken)
.send(Object.assign({}, self.validDoc, { subject: 'MODIFIED' }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field subject cannot be modified by the client');
});
it('should reject srvModified alteration', async () => {
let res = await self.instance.patch(self.urlToken)
.send(Object.assign({}, self.validDoc, { srvModified: self.validDoc.date - 100000 }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field srvModified cannot be modified by the client');
});
it('should reject modifiedBy alteration', async () => {
let res = await self.instance.patch(self.urlToken)
.send(Object.assign({}, self.validDoc, { modifiedBy: 'MODIFIED' }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field modifiedBy cannot be modified by the client');
});
it('should reject isValid alteration', async () => {
let res = await self.instance.patch(self.urlToken)
.send(Object.assign({}, self.validDoc, { isValid: false }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field isValid cannot be modified by the client');
});
it('should patch document', async () => {
self.validDoc.carbs = 10;
let res = await self.instance.patch(self.urlToken)
.send(self.validDoc)
.expect(204);
res.body.should.be.empty();
let body = await self.get(self.validDoc.identifier);
body.carbs.should.equal(10);
body.insulin.should.equal(0.3);
body.subject.should.equal(self.subject.apiCreate.name);
body.modifiedBy.should.equal(self.subject.apiUpdate.name);
});
});
+180
View File
@@ -0,0 +1,180 @@
/* eslint require-atomic-updates: 0 */
/* global should */
'use strict';
require('should');
describe('API3 READ', function() {
const self = this
, testConst = require('./fixtures/api3/const.json')
, instance = require('./fixtures/api3/instance')
, authSubject = require('./fixtures/api3/authSubject')
, opTools = require('../lib/api3/shared/operationTools')
;
self.validDoc = {
date: (new Date()).getTime(),
app: testConst.TEST_APP,
device: testConst.TEST_DEVICE,
uploaderBattery: 58
};
self.validDoc.identifier = opTools.calculateIdentifier(self.validDoc);
self.timeout(15000);
before(async () => {
self.instance = await instance.create({});
self.app = self.instance.app;
self.env = self.instance.env;
self.url = '/api/v3/devicestatus';
let authResult = await authSubject(self.instance.ctx.authorization.storage);
self.subject = authResult.subject;
self.token = authResult.token;
});
after(() => {
self.instance.server.close();
});
it('should require authentication', async () => {
let res = await self.instance.get(`${self.url}/FAKE_IDENTIFIER`)
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal('Missing or bad access token or JWT');
});
it('should not found not existing collection', async () => {
let res = await self.instance.get(`/api/v3/NOT_EXIST/NOT_EXIST?token=${self.url}`)
.send(self.validDoc)
.expect(404);
res.body.should.be.empty();
});
it('should not found not existing document', async () => {
await self.instance.get(`${self.url}/${self.validDoc.identifier}?token=${self.token.read}`)
.expect(404);
});
it('should read just created document', async () => {
let res = await self.instance.post(`${self.url}?token=${self.token.create}`)
.send(self.validDoc)
.expect(201);
res.body.should.be.empty();
res = await self.instance.get(`${self.url}/${self.validDoc.identifier}?token=${self.token.read}`)
.expect(200);
res.body.should.containEql(self.validDoc);
res.body.should.have.property('srvCreated').which.is.a.Number();
res.body.should.have.property('srvModified').which.is.a.Number();
res.body.should.have.property('subject');
self.validDoc.subject = res.body.subject; // let's store subject for later tests
});
it('should contain only selected fields', async () => {
let res = await self.instance.get(`${self.url}/${self.validDoc.identifier}?fields=date,device,subject&token=${self.token.read}`)
.expect(200);
const correct = {
date: self.validDoc.date,
device: self.validDoc.device,
subject: self.validDoc.subject
};
res.body.should.eql(correct);
});
it('should contain all fields', async () => {
let res = await self.instance.get(`${self.url}/${self.validDoc.identifier}?fields=_all&token=${self.token.read}`)
.expect(200);
for (let fieldName of ['app', 'date', 'device', 'identifier', 'srvModified', 'uploaderBattery', 'subject']) {
res.body.should.have.property(fieldName);
}
});
it('should not send unmodified document since', async () => {
let res = await self.instance.get(`${self.url}/${self.validDoc.identifier}?token=${self.token.read}`)
.set('If-Modified-Since', new Date(new Date().getTime() + 1000).toUTCString())
.expect(304);
res.body.should.be.empty();
});
it('should send modified document since', async () => {
let res = await self.instance.get(`${self.url}/${self.validDoc.identifier}?token=${self.token.read}`)
.set('If-Modified-Since', new Date(new Date(self.validDoc.date).getTime() - 1000).toUTCString())
.expect(200);
res.body.should.containEql(self.validDoc);
});
it('should recognize softly deleted document', async () => {
let res = await self.instance.delete(`${self.url}/${self.validDoc.identifier}?token=${self.token.delete}`)
.expect(204);
res.body.should.be.empty();
res = await self.instance.get(`${self.url}/${self.validDoc.identifier}?token=${self.token.read}`)
.expect(410);
res.body.should.be.empty();
});
it('should not found permanently deleted document', async () => {
let res = await self.instance.delete(`${self.url}/${self.validDoc.identifier}?permanent=true&token=${self.token.delete}`)
.expect(204);
res.body.should.be.empty();
res = await self.instance.get(`${self.url}/${self.validDoc.identifier}?token=${self.token.read}`)
.expect(404);
res.body.should.be.empty();
});
it('should found document created by APIv1', async () => {
const doc = Object.assign({}, self.validDoc, {
created_at: new Date(self.validDoc.date).toISOString()
});
delete doc.identifier;
self.instance.ctx.devicestatus.create([doc], async (err) => { // let's insert the document in APIv1's way
should.not.exist(err);
const identifier = doc._id.toString();
delete doc._id;
let res = await self.instance.get(`${self.url}/${identifier}?token=${self.token.read}`)
.expect(200);
res.body.should.containEql(doc);
res = await self.instance.delete(`${self.url}/${identifier}?permanent=true&token=${self.token.delete}`)
.expect(204);
res.body.should.be.empty();
});
});
});
+261
View File
@@ -0,0 +1,261 @@
/* eslint require-atomic-updates: 0 */
/* global should */
'use strict';
require('should');
describe('API3 SEARCH', function() {
const self = this
, testConst = require('./fixtures/api3/const.json')
, instance = require('./fixtures/api3/instance')
, authSubject = require('./fixtures/api3/authSubject')
, opTools = require('../lib/api3/shared/operationTools')
;
self.docs = testConst.SAMPLE_ENTRIES;
self.timeout(15000);
/**
* Get document detail for futher processing
*/
self.get = function get (identifier, done) {
self.instance.get(`${self.url}/${identifier}?token=${self.token.read}`)
.expect(200)
.end((err, res) => {
should.not.exist(err);
done(res.body);
});
};
/**
* Create given document in a promise
*/
self.create = (doc) => new Promise((resolve) => {
doc.identifier = opTools.calculateIdentifier(doc);
self.instance.post(`${self.url}?token=${self.token.all}`)
.send(doc)
.end((err) => {
should.not.exist(err);
self.get(doc.identifier, resolve);
});
});
before(async () => {
self.testStarted = new Date();
self.instance = await instance.create({});
self.app = self.instance.app;
self.env = self.instance.env;
self.url = '/api/v3/entries';
let authResult = await authSubject(self.instance.ctx.authorization.storage);
self.subject = authResult.subject;
self.token = authResult.token;
self.urlToken = `${self.url}?token=${self.token.read}`;
self.urlTest = `${self.urlToken}&srvModified$gte=${self.testStarted.getTime()}`;
const promises = testConst.SAMPLE_ENTRIES.map(doc => self.create(doc));
self.docs = await Promise.all(promises);
});
after(() => {
self.instance.server.close();
});
it('should require authentication', async () => {
let res = await self.instance.get(self.url)
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal('Missing or bad access token or JWT');
});
it('should not found not existing collection', async () => {
let res = await self.instance.get(`/api/v3/NOT_EXIST?token=${self.url}`)
.send(self.validDoc)
.expect(404);
res.body.should.be.empty();
});
it('should found at least 10 documents', async () => {
let res = await self.instance.get(self.urlToken)
.expect(200);
res.body.length.should.be.aboveOrEqual(self.docs.length);
});
it('should found at least 10 documents from test start', async () => {
let res = await self.instance.get(self.urlTest)
.expect(200);
res.body.length.should.be.aboveOrEqual(self.docs.length);
});
it('should reject invalid limit - not a number', async () => {
let res = await self.instance.get(`${self.urlToken}&limit=INVALID`)
.expect(400);
res.body.status.should.be.equal(400);
res.body.message.should.be.equal('Parameter limit out of tolerance');
});
it('should reject invalid limit - negative number', async () => {
let res = await self.instance.get(`${self.urlToken}&limit=-1`)
.expect(400);
res.body.status.should.be.equal(400);
res.body.message.should.be.equal('Parameter limit out of tolerance');
});
it('should reject invalid limit - zero', async () => {
let res = await self.instance.get(`${self.urlToken}&limit=0`)
.expect(400);
res.body.status.should.be.equal(400);
res.body.message.should.be.equal('Parameter limit out of tolerance');
});
it('should accept valid limit', async () => {
let res = await self.instance.get(`${self.urlToken}&limit=3`)
.expect(200);
res.body.length.should.be.equal(3);
});
it('should reject invalid skip - not a number', async () => {
let res = await self.instance.get(`${self.urlToken}&skip=INVALID`)
.expect(400);
res.body.status.should.be.equal(400);
res.body.message.should.be.equal('Parameter skip out of tolerance');
});
it('should reject invalid skip - negative number', async () => {
let res = await self.instance.get(`${self.urlToken}&skip=-5`)
.expect(400);
res.body.status.should.be.equal(400);
res.body.message.should.be.equal('Parameter skip out of tolerance');
});
it('should reject both sort and sort$desc', async () => {
let res = await self.instance.get(`${self.urlToken}&sort=date&sort$desc=created_at`)
.expect(400);
res.body.status.should.be.equal(400);
res.body.message.should.be.equal('Parameters sort and sort_desc cannot be combined');
});
it('should sort well by date field', async () => {
let res = await self.instance.get(`${self.urlTest}&sort=date`)
.expect(200);
const ascending = res.body;
const length = ascending.length;
length.should.be.aboveOrEqual(self.docs.length);
res = await self.instance.get(`${self.urlTest}&sort$desc=date`)
.expect(200);
const descending = res.body;
descending.length.should.equal(length);
for (let i in ascending) {
ascending[i].should.eql(descending[length - i - 1]);
if (i > 0) {
ascending[i - 1].date.should.be.lessThanOrEqual(ascending[i].date);
}
}
});
it('should skip documents', async () => {
let res = await self.instance.get(`${self.urlToken}&sort=date&limit=8`)
.expect(200);
const fullDocs = res.body;
fullDocs.length.should.be.equal(8);
res = await self.instance.get(`${self.urlToken}&sort=date&skip=3&limit=5`)
.expect(200);
const skipDocs = res.body;
skipDocs.length.should.be.equal(5);
for (let i = 0; i < 3; i++) {
skipDocs[i].should.be.eql(fullDocs[i + 3]);
}
});
it('should project selected fields', async () => {
let res = await self.instance.get(`${self.urlToken}&fields=date,app,subject`)
.expect(200);
res.body.forEach(doc => {
const docFields = Object.getOwnPropertyNames(doc);
docFields.sort().should.be.eql(['app', 'date', 'subject']);
});
});
it('should project all fields', async () => {
let res = await self.instance.get(`${self.urlToken}&fields=_all`)
.expect(200);
res.body.forEach(doc => {
Object.getOwnPropertyNames(doc).length.should.be.aboveOrEqual(10);
Object.prototype.hasOwnProperty.call(doc, '_id').should.not.be.true();
Object.prototype.hasOwnProperty.call(doc, 'identifier').should.be.true();
Object.prototype.hasOwnProperty.call(doc, 'srvModified').should.be.true();
Object.prototype.hasOwnProperty.call(doc, 'srvCreated').should.be.true();
});
});
it('should not exceed the limit of docs count', async () => {
const apiApp = self.instance.ctx.apiApp
, limitBackup = apiApp.get('API3_MAX_LIMIT');
apiApp.set('API3_MAX_LIMIT', 5);
let res = await self.instance.get(`${self.urlToken}&limit=10`)
.expect(400);
res.body.status.should.be.equal(400);
res.body.message.should.be.equal('Parameter limit out of tolerance');
apiApp.set('API3_MAX_LIMIT', limitBackup);
});
it('should respect the ceiling (hard) limit of docs', async () => {
const apiApp = self.instance.ctx.apiApp
, limitBackup = apiApp.get('API3_MAX_LIMIT');
apiApp.set('API3_MAX_LIMIT', 5);
let res = await self.instance.get(`${self.urlToken}`)
.expect(200);
res.body.length.should.be.equal(5);
apiApp.set('API3_MAX_LIMIT', limitBackup);
});
});
+189
View File
@@ -0,0 +1,189 @@
/* eslint require-atomic-updates: 0 */
'use strict';
const request = require('supertest')
, apiConst = require('../lib/api3/const.json')
, semver = require('semver')
, moment = require('moment')
;
require('should');
describe('Security of REST API3', function() {
const self = this
, instance = require('./fixtures/api3/instance')
, authSubject = require('./fixtures/api3/authSubject')
;
this.timeout(30000);
before(async () => {
self.http = await instance.create({ useHttps: false });
self.https = await instance.create({ });
let authResult = await authSubject(self.https.ctx.authorization.storage);
self.subject = authResult.subject;
self.token = authResult.token;
});
after(() => {
self.http.server.close();
self.https.server.close();
});
it('should require HTTPS', async () => {
if (semver.gte(process.version, '10.0.0')) {
let res = await request(self.http.baseUrl) // hangs on 8.x.x (no reason why)
.get('/api/v3/test')
.expect(403);
res.body.status.should.equal(403);
res.body.message.should.equal(apiConst.MSG.HTTP_403_NOT_USING_HTTPS);
}
});
it('should require Date header', async () => {
let res = await request(self.https.baseUrl)
.get('/api/v3/test')
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal(apiConst.MSG.HTTP_401_MISSING_DATE);
});
it('should validate Date header syntax', async () => {
let res = await request(self.https.baseUrl)
.get('/api/v3/test')
.set('Date', 'invalid date header')
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal(apiConst.MSG.HTTP_401_BAD_DATE);
});
it('should reject Date header out of tolerance', async () => {
const oldDate = new Date((new Date() * 1) - 2 * 3600 * 1000)
, futureDate = new Date((new Date() * 1) + 2 * 3600 * 1000);
let res = await request(self.https.baseUrl)
.get('/api/v3/test')
.set('Date', oldDate.toUTCString())
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal(apiConst.MSG.HTTP_401_DATE_OUT_OF_TOLERANCE);
res = await request(self.https.baseUrl)
.get('/api/v3/test')
.set('Date',futureDate.toUTCString())
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal(apiConst.MSG.HTTP_401_DATE_OUT_OF_TOLERANCE);
});
it('should reject invalid now ABC', async () => {
let res = await request(self.https.baseUrl)
.get(`/api/v3/test?now=ABC`)
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal('Bad Date header');
});
it('should reject invalid now -1', async () => {
let res = await request(self.https.baseUrl)
.get(`/api/v3/test?now=-1`)
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal('Bad Date header');
});
it('should reject invalid now - illegal format', async () => {
let res = await request(self.https.baseUrl)
.get(`/api/v3/test?now=2019-20-60T50:90:90`)
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal('Bad Date header');
});
it('should require token', async () => {
let res = await request(self.https.baseUrl)
.get('/api/v3/test')
.set('Date', new Date().toUTCString())
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal(apiConst.MSG.HTTP_401_MISSING_OR_BAD_TOKEN);
});
it('should require valid token', async () => {
let res = await request(self.https.baseUrl)
.get('/api/v3/test?token=invalid_token')
.set('Date', new Date().toUTCString())
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal(apiConst.MSG.HTTP_401_MISSING_OR_BAD_TOKEN);
});
it('should deny subject denied', async () => {
let res = await request(self.https.baseUrl)
.get('/api/v3/test?token=' + self.subject.denied.accessToken)
.set('Date', new Date().toUTCString())
.expect(403);
res.body.status.should.equal(403);
res.body.message.should.equal(apiConst.MSG.HTTP_403_MISSING_PERMISSION.replace('{0}', 'api:entries:read'));
});
it('should allow subject with read permission', async () => {
await request(self.https.baseUrl)
.get('/api/v3/test?token=' + self.token.read)
.set('Date', new Date().toUTCString())
.expect(200);
});
it('should accept valid now - epoch in ms', async () => {
await request(self.https.baseUrl)
.get(`/api/v3/test?token=${self.token.read}&now=${moment().valueOf()}`)
.expect(200);
});
it('should accept valid now - epoch in seconds', async () => {
await request(self.https.baseUrl)
.get(`/api/v3/test?token=${self.token.read}&now=${moment().unix()}`)
.expect(200);
});
it('should accept valid now - ISO 8601', async () => {
await request(self.https.baseUrl)
.get(`/api/v3/test?token=${self.token.read}&now=${moment().toISOString()}`)
.expect(200);
});
it('should accept valid now - RFC 2822', async () => {
await request(self.https.baseUrl)
.get(`/api/v3/test?token=${self.token.read}&now=${moment().utc().format('ddd, DD MMM YYYY HH:mm:ss [GMT]')}`)
.expect(200);
});
});
+178
View File
@@ -0,0 +1,178 @@
/* eslint require-atomic-updates: 0 */
/* global should */
'use strict';
require('should');
describe('Socket.IO in REST API3', function() {
const self = this
, testConst = require('./fixtures/api3/const.json')
, apiConst = require('../lib/api3/const.json')
, instance = require('./fixtures/api3/instance')
, authSubject = require('./fixtures/api3/authSubject')
, utils = require('./fixtures/api3/utils')
;
self.identifier = utils.randomString('32', 'aA#'); // let's have a brand new identifier for your testing document
self.docOriginal = {
identifier: self.identifier,
eventType: 'Correction Bolus',
insulin: 1,
date: (new Date()).getTime(),
app: testConst.TEST_APP
};
this.timeout(30000);
before(async () => {
self.instance = await instance.create({
storageSocket: true
});
self.app = self.instance.app;
self.env = self.instance.env;
self.colName = 'treatments';
self.urlCol = `/api/v3/${self.colName}`;
self.urlResource = self.urlCol + '/' + self.identifier;
self.urlHistory = self.urlCol + '/history';
let authResult = await authSubject(self.instance.ctx.authorization.storage);
self.subject = authResult.subject;
self.token = authResult.token;
self.socket = self.instance.clientSocket;
});
after(() => {
if(self.instance && self.instance.clientSocket && self.instance.clientSocket.connected) {
self.instance.clientSocket.disconnect();
}
self.instance.server.close();
});
it('should not subscribe without accessToken', done => {
self.socket.emit('subscribe', { }, function (data) {
data.success.should.not.equal(true);
data.message.should.equal(apiConst.MSG.SOCKET_MISSING_OR_BAD_ACCESS_TOKEN);
done();
});
});
it('should not subscribe by invalid accessToken', done => {
self.socket.emit('subscribe', { accessToken: 'INVALID' }, function (data) {
data.success.should.not.equal(true);
data.message.should.equal(apiConst.MSG.SOCKET_MISSING_OR_BAD_ACCESS_TOKEN);
done();
});
});
it('should not subscribe by subject with no rights', done => {
self.socket.emit('subscribe', { accessToken: self.token.denied }, function (data) {
data.success.should.not.equal(true);
data.message.should.equal(apiConst.MSG.SOCKET_UNAUTHORIZED_TO_ANY);
done();
});
});
it('should subscribe by valid accessToken', done => {
const cols = ['entries', 'treatments'];
self.socket.emit('subscribe', {
accessToken: self.token.all,
collections: cols
}, function (data) {
data.success.should.equal(true);
should(data.collections.sort()).be.eql(cols);
done();
});
});
it('should emit create event on CREATE', done => {
self.socket.once('create', (event) => {
event.colName.should.equal(self.colName);
event.doc.should.containEql(self.docOriginal);
delete event.doc.subject;
self.docActual = event.doc;
done();
});
self.instance.post(`${self.urlCol}?token=${self.token.create}`)
.send(self.docOriginal)
.expect(201)
.end((err) => {
should.not.exist(err);
});
});
it('should emit update event on UPDATE', done => {
self.docActual.insulin = 0.5;
self.socket.once('update', (event) => {
delete self.docActual.srvModified;
event.colName.should.equal(self.colName);
event.doc.should.containEql(self.docActual);
delete event.doc.subject;
self.docActual = event.doc;
done();
});
self.instance.put(`${self.urlResource}?token=${self.token.update}`)
.send(self.docActual)
.expect(204)
.end((err) => {
should.not.exist(err);
self.docActual.subject = self.subject.apiUpdate.name;
});
});
it('should emit update event on PATCH', done => {
self.docActual.carbs = 5;
self.docActual.insulin = 0.4;
self.socket.once('update', (event) => {
delete self.docActual.srvModified;
event.colName.should.equal(self.colName);
event.doc.should.containEql(self.docActual);
delete event.doc.subject;
self.docActual = event.doc;
done();
});
self.instance.patch(`${self.urlResource}?token=${self.token.update}`)
.send({ 'carbs': self.docActual.carbs, 'insulin': self.docActual.insulin })
.expect(204)
.end((err) => {
should.not.exist(err);
});
});
it('should emit delete event on DELETE', done => {
self.socket.once('delete', (event) => {
event.colName.should.equal(self.colName);
event.identifier.should.equal(self.identifier);
done();
});
self.instance.delete(`${self.urlResource}?token=${self.token.delete}`)
.expect(204)
.end((err) => {
should.not.exist(err);
});
});
});
+289
View File
@@ -0,0 +1,289 @@
/* eslint require-atomic-updates: 0 */
/* global should */
'use strict';
require('should');
describe('API3 UPDATE', function() {
const self = this
, testConst = require('./fixtures/api3/const.json')
, instance = require('./fixtures/api3/instance')
, authSubject = require('./fixtures/api3/authSubject')
, utils = require('./fixtures/api3/utils')
;
self.validDoc = {
identifier: utils.randomString('32', 'aA#'),
date: (new Date()).getTime(),
utcOffset: -180,
app: testConst.TEST_APP,
eventType: 'Correction Bolus',
insulin: 0.3
};
self.timeout(15000);
/**
* Get document detail for futher processing
*/
self.get = async function get (identifier) {
let res = await self.instance.get(`${self.url}/${identifier}?token=${self.token.read}`)
.expect(200);
return res.body;
};
before(async () => {
self.instance = await instance.create({});
self.app = self.instance.app;
self.env = self.instance.env;
self.url = '/api/v3/treatments';
let authResult = await authSubject(self.instance.ctx.authorization.storage);
self.subject = authResult.subject;
self.token = authResult.token;
self.urlToken = `${self.url}/${self.validDoc.identifier}?token=${self.token.update}`
});
after(() => {
self.instance.server.close();
});
it('should require authentication', async () => {
let res = await self.instance.put(`${self.url}/FAKE_IDENTIFIER`)
.expect(401);
res.body.status.should.equal(401);
res.body.message.should.equal('Missing or bad access token or JWT');
});
it('should not found not existing collection', async () => {
let res = await self.instance.put(`/api/v3/NOT_EXIST?token=${self.url}`)
.send(self.validDoc)
.expect(404);
res.body.should.be.empty();
});
it('should require update permission for upsert', async () => {
let res = await self.instance.put(`${self.url}/${self.validDoc.identifier}?token=${self.token.update}`)
.send(self.validDoc)
.expect(403);
res.body.status.should.equal(403);
res.body.message.should.equal('Missing permission api:treatments:create');
});
it('should upsert not existing document', async () => {
let res = await self.instance.put(`${self.url}/${self.validDoc.identifier}?token=${self.token.all}`)
.send(self.validDoc)
.expect(201);
res.body.should.be.empty();
const lastModified = new Date(res.headers['last-modified']).getTime(); // Last-Modified has trimmed milliseconds
let body = await self.get(self.validDoc.identifier);
body.should.containEql(self.validDoc);
should.not.exist(body.modifiedBy);
const ms = body.srvModified % 1000;
(body.srvModified - ms).should.equal(lastModified);
(body.srvCreated - ms).should.equal(lastModified);
body.subject.should.equal(self.subject.apiAll.name);
});
it('should update the document', async () => {
self.validDoc.carbs = 10;
delete self.validDoc.insulin;
let res = await self.instance.put(self.urlToken)
.send(self.validDoc)
.expect(204);
res.body.should.be.empty();
const lastModified = new Date(res.headers['last-modified']).getTime(); // Last-Modified has trimmed milliseconds
let body = await self.get(self.validDoc.identifier);
body.should.containEql(self.validDoc);
should.not.exist(body.insulin);
should.not.exist(body.modifiedBy);
const ms = body.srvModified % 1000;
(body.srvModified - ms).should.equal(lastModified);
body.subject.should.equal(self.subject.apiUpdate.name);
});
it('should update unmodified document since', async () => {
const doc = Object.assign({}, self.validDoc, {
carbs: 11
});
let res = await self.instance.put(self.urlToken)
.set('If-Unmodified-Since', new Date(new Date().getTime() + 1000).toUTCString())
.send(doc)
.expect(204);
res.body.should.be.empty();
let body = await self.get(self.validDoc.identifier);
body.should.containEql(doc);
});
it('should not update document modified since', async () => {
const doc = Object.assign({}, self.validDoc, {
carbs: 12
});
let body = await self.get(doc.identifier);
self.validDoc = body;
let res = await self.instance.put(self.urlToken)
.set('If-Unmodified-Since', new Date(new Date(body.srvModified).getTime() - 1000).toUTCString())
.send(doc)
.expect(412);
res.body.should.be.empty();
body = await self.get(doc.identifier);
body.should.eql(self.validDoc);
});
it('should reject date alteration', async () => {
let res = await self.instance.put(self.urlToken)
.send(Object.assign({}, self.validDoc, { date: self.validDoc.date + 10000 }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field date cannot be modified by the client');
});
it('should reject utcOffset alteration', async () => {
let res = await self.instance.put(self.urlToken)
.send(Object.assign({}, self.validDoc, { utcOffset: self.utcOffset - 120 }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field utcOffset cannot be modified by the client');
});
it('should reject eventType alteration', async () => {
let res = await self.instance.put(self.urlToken)
.send(Object.assign({}, self.validDoc, { eventType: 'MODIFIED' }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field eventType cannot be modified by the client');
});
it('should reject device alteration', async () => {
let res = await self.instance.put(self.urlToken)
.send(Object.assign({}, self.validDoc, { device: 'MODIFIED' }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field device cannot be modified by the client');
});
it('should reject app alteration', async () => {
let res = await self.instance.put(self.urlToken)
.send(Object.assign({}, self.validDoc, { app: 'MODIFIED' }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field app cannot be modified by the client');
});
it('should reject srvCreated alteration', async () => {
let res = await self.instance.put(self.urlToken)
.send(Object.assign({}, self.validDoc, { srvCreated: self.validDoc.date - 10000 }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field srvCreated cannot be modified by the client');
});
it('should reject subject alteration', async () => {
let res = await self.instance.put(self.urlToken)
.send(Object.assign({}, self.validDoc, { subject: 'MODIFIED' }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field subject cannot be modified by the client');
});
it('should reject srvModified alteration', async () => {
let res = await self.instance.put(self.urlToken)
.send(Object.assign({}, self.validDoc, { srvModified: self.validDoc.date - 100000 }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field srvModified cannot be modified by the client');
});
it('should reject modifiedBy alteration', async () => {
let res = await self.instance.put(self.urlToken)
.send(Object.assign({}, self.validDoc, { modifiedBy: 'MODIFIED' }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field modifiedBy cannot be modified by the client');
});
it('should reject isValid alteration', async () => {
let res = await self.instance.put(self.urlToken)
.send(Object.assign({}, self.validDoc, { isValid: false }))
.expect(400);
res.body.status.should.equal(400);
res.body.message.should.equal('Field isValid cannot be modified by the client');
});
it('should ignore identifier alteration in body', async () => {
self.validDoc = await self.get(self.validDoc.identifier);
let res = await self.instance.put(self.urlToken)
.send(Object.assign({}, self.validDoc, { identifier: 'MODIFIED' }))
.expect(204);
res.body.should.be.empty();
});
it('should not update deleted document', async () => {
let res = await self.instance.delete(`${self.url}/${self.validDoc.identifier}?token=${self.token.delete}`)
.expect(204);
res.body.should.be.empty();
res = await self.instance.put(self.urlToken)
.send(self.validDoc)
.expect(410);
res.body.should.be.empty();
});
});
+94
View File
@@ -0,0 +1,94 @@
'use strict';
const _ = require('lodash');
function createRole (authStorage, name, permissions) {
return new Promise((resolve, reject) => {
let role = _.find(authStorage.roles, { name });
if (role) {
resolve(role);
}
else {
authStorage.createRole({
"name": name,
"permissions": permissions,
"notes": ""
}, function afterCreate (err) {
if (err)
reject(err);
role = _.find(authStorage.roles, { name });
resolve(role);
});
}
});
}
function createTestSubject (authStorage, subjectName, roles) {
return new Promise((resolve, reject) => {
const subjectDbName = 'test-' + subjectName;
let subject = _.find(authStorage.subjects, { name: subjectDbName });
if (subject) {
resolve(subject);
}
else {
authStorage.createSubject({
"name": subjectDbName,
"roles": roles,
"notes": ""
}, function afterCreate (err) {
if (err)
reject(err);
subject = _.find(authStorage.subjects, { name: subjectDbName });
resolve(subject);
});
}
});
}
async function authSubject (authStorage) {
await createRole(authStorage, 'apiAll', 'api:*:*');
await createRole(authStorage, 'apiAdmin', 'api:*:admin');
await createRole(authStorage, 'apiCreate', 'api:*:create');
await createRole(authStorage, 'apiRead', 'api:*:read');
await createRole(authStorage, 'apiUpdate', 'api:*:update');
await createRole(authStorage, 'apiDelete', 'api:*:delete');
const subject = {
apiAll: await createTestSubject(authStorage, 'apiAll', ['apiAll']),
apiAdmin: await createTestSubject(authStorage, 'apiAdmin', ['apiAdmin']),
apiCreate: await createTestSubject(authStorage, 'apiCreate', ['apiCreate']),
apiRead: await createTestSubject(authStorage, 'apiRead', ['apiRead']),
apiUpdate: await createTestSubject(authStorage, 'apiUpdate', ['apiUpdate']),
apiDelete: await createTestSubject(authStorage, 'apiDelete', ['apiDelete']),
admin: await createTestSubject(authStorage, 'admin', ['admin']),
readable: await createTestSubject(authStorage, 'readable', ['readable']),
denied: await createTestSubject(authStorage, 'denied', ['denied'])
};
const token = {
all: subject.apiAll.accessToken,
admin: subject.apiAdmin.accessToken,
create: subject.apiCreate.accessToken,
read: subject.apiRead.accessToken,
update: subject.apiUpdate.accessToken,
delete: subject.apiDelete.accessToken,
denied: subject.denied.accessToken
};
return {subject, token};
}
module.exports = authSubject;
+138
View File
@@ -0,0 +1,138 @@
{
"YEAR_2019": 1546304400000,
"YEAR_2050": 2524611600000,
"TEST_APP": "cgm-remote-monitor.test",
"TEST_DEVICE": "Samsung XCover 4-123456735643809",
"SAMPLE_ENTRIES": [
{
"date": 1491717830000.0,
"device": "dexcom",
"direction": "FortyFiveUp",
"filtered": 167584,
"noise": 2,
"rssi": 183,
"sgv": 149,
"type": "sgv",
"unfiltered": 171584,
"app": "cgm-remote-monitor.test"
},
{
"date": 1491718130000.0,
"device": "dexcom",
"direction": "FortyFiveUp",
"filtered": 170656,
"noise": 2,
"rssi": 181,
"sgv": 152,
"type": "sgv",
"unfiltered": 175776,
"app": "cgm-remote-monitor.test"
},
{
"date": 1491718430000.0,
"device": "dexcom",
"direction": "Flat",
"filtered": 173536,
"noise": 2,
"rssi": 185,
"sgv": 155,
"type": "sgv",
"unfiltered": 180864,
"app": "cgm-remote-monitor.test"
},
{
"date": 1491718730000.0,
"device": "dexcom",
"direction": "Flat",
"filtered": 177120,
"noise": 2,
"rssi": 186,
"sgv": 159,
"type": "sgv",
"unfiltered": 182080,
"app": "cgm-remote-monitor.test"
},
{
"date": 1491719030000.0,
"device": "dexcom",
"direction": "Flat",
"filtered": 181088,
"noise": 2,
"rssi": 165,
"sgv": 163,
"type": "sgv",
"unfiltered": 186912,
"app": "cgm-remote-monitor.test"
},
{
"date": 1491719330000.0,
"device": "dexcom",
"direction": "Flat",
"filtered": 184736,
"noise": 1,
"rssi": 162,
"sgv": 170,
"type": "sgv",
"unfiltered": 188512,
"app": "cgm-remote-monitor.test"
},
{
"date": 1491719630000.0,
"device": "dexcom",
"direction": "Flat",
"filtered": 187776,
"noise": 1,
"rssi": 175,
"sgv": 175,
"type": "sgv",
"unfiltered": 192608,
"app": "cgm-remote-monitor.test"
},
{
"date": 1491719930000.0,
"device": "dexcom",
"direction": "Flat",
"filtered": 190816,
"noise": 1,
"rssi": 181,
"sgv": 179,
"type": "sgv",
"unfiltered": 196640,
"app": "cgm-remote-monitor.test"
},
{
"date": 1491720230000.0,
"device": "dexcom",
"direction": "Flat",
"filtered": 194016,
"noise": 1,
"rssi": 203,
"sgv": 181,
"type": "sgv",
"unfiltered": 199008,
"app": "cgm-remote-monitor.test"
},
{
"date": 1491720530000.0,
"device": "dexcom",
"direction": "Flat",
"filtered": 197536,
"noise": 1,
"rssi": 184,
"sgv": 186,
"type": "sgv",
"unfiltered": 203296,
"app": "cgm-remote-monitor.test"
}
]
}
+163
View File
@@ -0,0 +1,163 @@
'use strict';
var fs = require('fs')
, language = require('../../../lib/language')()
, api = require('../../../lib/api3/')
, http = require('http')
, https = require('https')
, request = require('supertest')
, websocket = require('../../../lib/server/websocket')
, io = require('socket.io-client')
;
function configure () {
const self = { };
self.prepareEnv = function prepareEnv({ apiSecret, useHttps, authDefaultRoles, enable }) {
if (useHttps) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
else {
process.env.INSECURE_USE_HTTP = true;
}
process.env.API_SECRET = apiSecret;
process.env.HOSTNAME = 'localhost';
const env = require('../../../env')();
if (useHttps) {
env.ssl = {
key: fs.readFileSync(__dirname + '/localhost.key'),
cert: fs.readFileSync(__dirname + '/localhost.crt')
};
}
env.settings.authDefaultRoles = authDefaultRoles;
env.settings.enable = enable;
return env;
};
self.addSecuredOperations = function addSecuredOperations (instance) {
instance.get = (url) => request(instance.baseUrl).get(url).set('Date', new Date().toUTCString());
instance.post = (url) => request(instance.baseUrl).post(url).set('Date', new Date().toUTCString());
instance.put = (url) => request(instance.baseUrl).put(url).set('Date', new Date().toUTCString());
instance.patch = (url) => request(instance.baseUrl).patch(url).set('Date', new Date().toUTCString());
instance.delete = (url) => request(instance.baseUrl).delete(url).set('Date', new Date().toUTCString());
};
self.bindSocket = function bindSocket (storageSocket, instance) {
return new Promise(function (resolve, reject) {
if (!storageSocket) {
resolve();
}
else {
let socket = io(`${instance.baseUrl}/storage`, {
origins:"*",
transports: ['websocket', 'flashsocket', 'polling'],
rejectUnauthorized: false
});
socket.on('connect', function () {
resolve(socket);
});
socket.on('connect_error', function (error) {
console.error(error);
reject(error);
});
}
});
};
self.unbindSocket = function unbindSocket (instance) {
if (instance.clientSocket.connected) {
instance.clientSocket.disconnect();
}
};
/*
* Create new web server instance for testing purposes
*/
self.create = function createHttpServer ({
apiSecret = 'this is my long pass phrase',
disableSecurity = false,
useHttps = true,
authDefaultRoles = '',
enable = ['careportal', 'api'],
storageSocket = null
}) {
return new Promise(function (resolve, reject) {
try {
let instance = { },
hasBooted = false
;
instance.env = self.prepareEnv({ apiSecret, useHttps, authDefaultRoles, enable });
self.wares = require('../../../lib/middleware/')(instance.env);
instance.app = require('express')();
instance.app.enable('api');
require('../../../lib/server/bootevent')(instance.env, language).boot(function booted (ctx) {
instance.ctx = ctx;
instance.ctx.ddata = require('../../../lib/data/ddata')();
instance.ctx.apiApp = api(instance.env, ctx);
if (disableSecurity) {
instance.ctx.apiApp.set('API3_SECURITY_ENABLE', false);
}
instance.app.use('/api/v3', instance.ctx.apiApp);
const transport = useHttps ? https : http;
instance.server = transport.createServer(instance.env.ssl || { }, instance.app).listen(0);
instance.env.PORT = instance.server.address().port;
instance.baseUrl = `${useHttps ? 'https' : 'http'}://${instance.env.HOSTNAME}:${instance.env.PORT}`;
self.addSecuredOperations(instance);
websocket(instance.env, instance.ctx, instance.server);
self.bindSocket(storageSocket, instance)
.then((socket) => {
instance.clientSocket = socket;
console.log(`Started ${useHttps ? 'SSL' : 'HTTP'} instance on ${instance.baseUrl}`);
hasBooted = true;
resolve(instance);
})
.catch((reason) => {
console.error(reason);
reject(reason);
});
});
setTimeout(function watchDog() {
if (!hasBooted)
reject('timeout');
}, 30000);
} catch (err) {
reject(err);
}
});
};
return self;
}
module.exports = configure();
+18
View File
@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC+zCCAeOgAwIBAgIJAIx0y57dTqDpMA0GCSqGSIb3DQEBBQUAMBQxEjAQBgNV
BAMMCWxvY2FsaG9zdDAeFw0xOTAyMDQxOTM1MDhaFw0yOTAyMDExOTM1MDhaMBQx
EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
ggEBAKCeBaqAJU+nrzNUZMsD1jYQpmcw8+6tG69KQY2XmqMsaPupo2ArwUlYD3pm
F1HTf9Lkq8u07rlUyMaSSRYrY56vPrMWGSK5Elm4kF8DNS4b/55KwZC+YQM0ZuJK
wSM6WX4G7JwV936HKJAT+Ec+8Ofq3GQzA9+Z4x2zMwNGC8AghtPjsCk68ORCmr+5
fdCdC1Rz9hE92Nmofi8e1hUTeZmFROx8hcYRhxYXLIWVxALc/t8yY3MZfsRuZXcP
/3PageAn0ecxhqlWBY23GDQx7OSEZxSEPgqxnAHQfQXIrPRjMkFNHeMM7HTvITAG
VCc99zEG3Jy5hatm+RAajdWBH4sCAwEAAaNQME4wHQYDVR0OBBYEFJJVZn5Y91O7
JUKeHW4La8eseKKwMB8GA1UdIwQYMBaAFJJVZn5Y91O7JUKeHW4La8eseKKwMAwG
A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAFOU19t9h6C1Hakkik/93kun
pwG7v8VvDPjKECR5KlNPKNZUOQaiMAVHgNwPWV8q+qvfydzIpDrTd/O5eOaOduLx
gDVDj078Q05j17RUC+ct5yQ6lPgEHlnkI0Zr/hgFyNC+mtK7oIm6BT8wSSRbv7AG
3wQzCA5UvW/BQ8rtNZSC42Jyr0BR0ZS9Fo3Gc4v/nZJlgkiBvU2gKVQ7VRKxybCn
0hDghVwTfBPq7PKmupLX82ktwhYpDJZXCsOVfq9mF6nbQ6b0MieXFD+7cBlEXb1e
3VgtVzKYyqh/Oex4HfMThzAJZSWa0E4FShr5XdTdIc3nB4Vgbsis5l9Yrcp3Xo4=
-----END CERTIFICATE-----
+27
View File
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAoJ4FqoAlT6evM1RkywPWNhCmZzDz7q0br0pBjZeaoyxo+6mj
YCvBSVgPemYXUdN/0uSry7TuuVTIxpJJFitjnq8+sxYZIrkSWbiQXwM1Lhv/nkrB
kL5hAzRm4krBIzpZfgbsnBX3focokBP4Rz7w5+rcZDMD35njHbMzA0YLwCCG0+Ow
KTrw5EKav7l90J0LVHP2ET3Y2ah+Lx7WFRN5mYVE7HyFxhGHFhcshZXEAtz+3zJj
cxl+xG5ldw//c9qB4CfR5zGGqVYFjbcYNDHs5IRnFIQ+CrGcAdB9Bcis9GMyQU0d
4wzsdO8hMAZUJz33MQbcnLmFq2b5EBqN1YEfiwIDAQABAoIBADoh95sGVnrGDjtd
yD1SXi2jSRcAOMmiDesbzS4aOPXmFPlBJMiiDYsmPDPoz3fmPNVvvl40VlLtxN1a
BOnpOl0swFzBGsfehC3FBzvcRVsy9wmrtPNWdHZceQBeXhkJ/WoHx4uWx8Ub1iqP
j8T5mufVsX7yl+xOHk2ZllUQ/R/EEz9x00pkiH8Vsn8DhFI5KNqgi4n4c36T3vrn
MjTp+1o7bJ/cEnvXLi+IG2CO5y5hVbu3iKb+71YOGc6f/AJVzZ3MegC3KMFho9lh
DbDzumMuW8fZNyBfslXXoOr6oDqNq92n/jC/2hR8Xlth/aafisJiIVGydeVdDXhM
gDjdroECgYEAy3hXuo/Q1acncInGhIJvHjS/sVShP2epHz9zp8XuWl4NCuGP5V2c
jLT0hDW+ZKTUFweK9sQJNta81gs4pYc+2HGI8RP65XW4vgesNoKbBcE9xhEq0HMX
KN3/MJiwkNkM95T3nWqulhzNszhgNbZDMAU3Ule+o4n8udwOlFCTeXMCgYEAyhV4
PoL3wp05BY0ssyKEqld3EqHNlPdQeJe1Dg9LSBy+3Z9sNngRD1/FuTo7RX6UY0FH
MaSI1JwhHSQ+2GNkqdMvVAilTXIDRw8vU9B77bYiHjny8+vMU06I9V3cJ57bNfmR
NUJtPmGO9xQ5UYxhP9rFOcI4MIecSzu1tvqiG4kCgYB01NoS7sdsFrnnvcS2i6rA
PmufqEeaf6w1nBqNyHJPg1eb2t7kRfdBOBp6291CLv71Zkhd3zynN3BguzrAmUL1
x2Npgh57qTf2LbOt7RqUmFwfIfZikONIfQgt4E7qLSdr9iakRgCPg2R9ty5PSSOV
LDmS131IrE/obLoWYZn8jwKBgQDIaAxMahONA+CFueCHcgcA6yah6qZ3QeCjB0g9
vjsZM7CxFqX5So8YoRDzxWT8YTCFUjppZ9NujbtlLAnLDJ7KsC2yd7R/Hj9T3CJC
S3JrZoFlWnCvJ7wFLdAzDTcEb8zTNUGlANBX2eYu7/Z8Aex7p9iJlCunLQV5sqhd
4yaaiQKBgQCERrz1XcJpM8S93nXdAv3Nn1bwA1V/ylx42DRxNEBl2JZQ1sQeqN36
JvXPXhVZ3vTQDhVUqcVgqJIAb2xMviIVBnssOq3+pi/hOs13rakJf4AOulZ/3Si7
HSLdymfQAMEKczU2261kw4pjPwiurkjAFWbQG2C8RGE/rR2y38PkDg==
-----END RSA PRIVATE KEY-----
+21
View File
@@ -0,0 +1,21 @@
'use strict';
function randomString (length, chars) {
let mask = '';
if (chars.indexOf('a') > -1) mask += 'abcdefghijklmnopqrstuvwxyz';
if (chars.indexOf('A') > -1) mask += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if (chars.indexOf('#') > -1) mask += '0123456789';
if (chars.indexOf('!') > -1) mask += '~`!@#$%^&*()_+-={}[]:";\'<>?,./|\\';
let result = '';
for (let i = length; i > 0; --i)
result += mask[Math.floor(Math.random() * mask.length)];
return result;
}
module.exports = {
randomString
};