pryv 3.0.2 → 3.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/Service.js +376 -6
- package/src/index.d.ts +108 -1
- package/src/index.js +4 -1
- package/src/lib/MfaRequiredError.js +46 -0
- package/src/lib/PryvError.js +38 -4
- package/src/lib/errorIds.js +67 -0
- package/test/PryvError.test.js +34 -0
- package/test/Service.accessRequest.test.js +78 -0
- package/test/Service.createUser.test.js +104 -0
- package/test/Service.hostings.test.js +53 -0
- package/test/Service.mfa.test.js +94 -0
- package/test/Service.passwordReset.test.js +87 -0
- package/test/Service.userExists.test.js +29 -0
- package/test/Service.userIdForEmail.test.js +30 -0
package/package.json
CHANGED
package/src/Service.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
4
|
*/
|
|
5
5
|
const utils = require('./utils.js');
|
|
6
|
+
const PryvError = require('./lib/PryvError.js');
|
|
7
|
+
const MfaRequiredError = require('./lib/MfaRequiredError.js');
|
|
6
8
|
// Connection is required at the end of this file to allow circular requires.
|
|
7
9
|
const Assets = require('./ServiceAssets.js');
|
|
8
10
|
|
|
@@ -182,20 +184,388 @@ class Service {
|
|
|
182
184
|
);
|
|
183
185
|
|
|
184
186
|
if (!response.ok) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
throw PryvError.fromApiResponse(response, body);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (body && body.mfaToken) {
|
|
191
|
+
throw new MfaRequiredError(body.mfaToken, response, body);
|
|
189
192
|
}
|
|
190
193
|
|
|
191
|
-
if (!body.token) {
|
|
192
|
-
throw new
|
|
194
|
+
if (!body || !body.token) {
|
|
195
|
+
throw new PryvError(
|
|
196
|
+
'Invalid login response: ' + JSON.stringify(body)
|
|
197
|
+
);
|
|
193
198
|
}
|
|
194
199
|
return new Connection(
|
|
195
200
|
Service.buildAPIEndpoint(await this.info(), username, body.token),
|
|
196
201
|
this // Pre load Connection with service
|
|
197
202
|
);
|
|
198
203
|
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Re-trigger an MFA challenge (e.g. resend SMS) during a pending login.
|
|
207
|
+
* Use after `login()` threw `MfaRequiredError` if the user needs another
|
|
208
|
+
* SMS code.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} userId
|
|
211
|
+
* @param {string} mfaToken - From `MfaRequiredError.mfaToken`
|
|
212
|
+
* @returns {Promise<void>}
|
|
213
|
+
* @throws {PryvError} on 4xx/5xx (e.g. invalid/expired mfaToken)
|
|
214
|
+
*/
|
|
215
|
+
async mfaChallenge (userId, mfaToken) {
|
|
216
|
+
if (!userId || !mfaToken) {
|
|
217
|
+
throw new PryvError('mfaChallenge requires userId and mfaToken');
|
|
218
|
+
}
|
|
219
|
+
const url = await this.apiEndpointFor(userId) + 'mfa/challenge';
|
|
220
|
+
const { response, body } = await utils.fetchPost(url, {}, {
|
|
221
|
+
Authorization: mfaToken
|
|
222
|
+
});
|
|
223
|
+
if (!response.ok) throw PryvError.fromApiResponse(response, body);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Finish an MFA-protected login by submitting the SMS code. Returns a
|
|
228
|
+
* fully-formed `Connection` (parallel to `Service.login`).
|
|
229
|
+
*
|
|
230
|
+
* @param {string} userId
|
|
231
|
+
* @param {string} mfaToken - From `MfaRequiredError.mfaToken`
|
|
232
|
+
* @param {string} code - The SMS verification code
|
|
233
|
+
* @returns {Promise<Connection>}
|
|
234
|
+
* @throws {PryvError} on bad code, expired mfaToken, etc.
|
|
235
|
+
*/
|
|
236
|
+
async mfaVerify (userId, mfaToken, code) {
|
|
237
|
+
if (!userId || !mfaToken || code == null) {
|
|
238
|
+
throw new PryvError('mfaVerify requires userId, mfaToken, code');
|
|
239
|
+
}
|
|
240
|
+
const url = await this.apiEndpointFor(userId) + 'mfa/verify';
|
|
241
|
+
const { response, body } = await utils.fetchPost(url, { code }, {
|
|
242
|
+
Authorization: mfaToken
|
|
243
|
+
});
|
|
244
|
+
if (!response.ok) throw PryvError.fromApiResponse(response, body);
|
|
245
|
+
if (!body || !body.token) {
|
|
246
|
+
throw new PryvError(
|
|
247
|
+
'mfa.verify did not return a token: ' + JSON.stringify(body)
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return new Connection(
|
|
251
|
+
Service.buildAPIEndpoint(await this.info(), userId, body.token),
|
|
252
|
+
this
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Check whether a username is registered on this service.
|
|
258
|
+
* One round-trip via `POST <register>/<userId>/server`.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} userId - The username to check
|
|
261
|
+
* @returns {Promise<boolean>} `true` if registered, `false` on 404
|
|
262
|
+
* @throws {PryvError} on network errors or non-404 API errors
|
|
263
|
+
*/
|
|
264
|
+
async userExists (userId) {
|
|
265
|
+
const serviceInfo = await this.info();
|
|
266
|
+
const url = serviceInfo.register + encodeURIComponent(userId) + '/server';
|
|
267
|
+
const { response, body } = await utils.fetchPost(url, {});
|
|
268
|
+
if (response.ok) return true;
|
|
269
|
+
if (response.status === 404) return false;
|
|
270
|
+
throw PryvError.fromApiResponse(response, body);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Resolve an email address to a username on this service.
|
|
275
|
+
* One round-trip via `GET <register>/<email>/uid`.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} email - The email to look up
|
|
278
|
+
* @returns {Promise<string|null>} The username, or `null` if unknown
|
|
279
|
+
* @throws {PryvError} on network errors or non-404 API errors
|
|
280
|
+
*/
|
|
281
|
+
async userIdForEmail (email) {
|
|
282
|
+
const serviceInfo = await this.info();
|
|
283
|
+
const url = serviceInfo.register + encodeURIComponent(email) + '/uid';
|
|
284
|
+
const { response, body } = await utils.fetchGet(url);
|
|
285
|
+
if (response.ok) return (body && (body.uid || body.username)) || null;
|
|
286
|
+
if (response.status === 404) return null;
|
|
287
|
+
throw PryvError.fromApiResponse(response, body);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Fetch the raw hostings tree advertised by `<register>/hostings`.
|
|
292
|
+
*
|
|
293
|
+
* Returns the nested API shape `{ regions: { <region>: { zones:
|
|
294
|
+
* { <zone>: { hostings: { <key>: { name, description, availableCore,
|
|
295
|
+
* available } } } } } } }`. For a flat list ready to render in a UI,
|
|
296
|
+
* use `flatHostings()`.
|
|
297
|
+
*
|
|
298
|
+
* @returns {Promise<Object>} the raw `/reg/hostings` body
|
|
299
|
+
* @throws {PryvError} on non-2xx
|
|
300
|
+
*/
|
|
301
|
+
async availableHostings () {
|
|
302
|
+
const serviceInfo = await this.info();
|
|
303
|
+
const { response, body } = await utils.fetchGet(
|
|
304
|
+
serviceInfo.register + 'hostings'
|
|
305
|
+
);
|
|
306
|
+
if (!response.ok) throw PryvError.fromApiResponse(response, body);
|
|
307
|
+
return body;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Flatten `availableHostings()` into a list of `{ key, name, description,
|
|
312
|
+
* region, zone, availableCore, available }` items.
|
|
313
|
+
*
|
|
314
|
+
* @returns {Promise<Array<Object>>}
|
|
315
|
+
* @throws {PryvError} on non-2xx
|
|
316
|
+
*/
|
|
317
|
+
async flatHostings () {
|
|
318
|
+
const tree = await this.availableHostings();
|
|
319
|
+
const out = [];
|
|
320
|
+
const regions = (tree && tree.regions) || {};
|
|
321
|
+
for (const [regionKey, region] of Object.entries(regions)) {
|
|
322
|
+
const zones = (region && region.zones) || {};
|
|
323
|
+
for (const [zoneKey, zone] of Object.entries(zones)) {
|
|
324
|
+
const hostings = (zone && zone.hostings) || {};
|
|
325
|
+
for (const [key, h] of Object.entries(hostings)) {
|
|
326
|
+
if (!h) continue;
|
|
327
|
+
out.push({
|
|
328
|
+
key,
|
|
329
|
+
name: h.name,
|
|
330
|
+
description: h.description,
|
|
331
|
+
region: regionKey,
|
|
332
|
+
zone: zoneKey,
|
|
333
|
+
availableCore: h.availableCore,
|
|
334
|
+
available: h.available === true
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Register a new user on this service.
|
|
344
|
+
*
|
|
345
|
+
* Hides the v1/v2 register endpoint difference. v2 platforms (service
|
|
346
|
+
* version >= 2.0 or >= 1.6) accept camelCase fields at `<register>users`;
|
|
347
|
+
* older v1 service-register expects mixed-case fields at `<register>user`.
|
|
348
|
+
*
|
|
349
|
+
* Pass `hosting: 'auto'` to use the first hosting flagged `available: true`
|
|
350
|
+
* in `flatHostings()` — useful for tests and single-hosting platforms.
|
|
351
|
+
*
|
|
352
|
+
* @param {Object} opts
|
|
353
|
+
* @param {string} opts.username
|
|
354
|
+
* @param {string} opts.password
|
|
355
|
+
* @param {string} opts.email
|
|
356
|
+
* @param {string} opts.hosting - Hosting key (use `service.flatHostings()` to discover) or `'auto'`
|
|
357
|
+
* @param {string} opts.appId
|
|
358
|
+
* @param {string} [opts.language='en']
|
|
359
|
+
* @param {string} [opts.invitationToken='enjoy']
|
|
360
|
+
* @param {string} [opts.referer]
|
|
361
|
+
* @returns {Promise<{ username: string, apiEndpoint: string }>}
|
|
362
|
+
* @throws {PryvError} on duplicate username, weak password, etc.
|
|
363
|
+
*/
|
|
364
|
+
async createUser (opts) {
|
|
365
|
+
if (!opts || !opts.username || !opts.password || !opts.email ||
|
|
366
|
+
!opts.hosting || !opts.appId) {
|
|
367
|
+
throw new PryvError(
|
|
368
|
+
'createUser requires username, password, email, hosting, appId'
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
const serviceInfo = await this.info();
|
|
372
|
+
const isModern = supportsCamelCaseRegister(serviceInfo.version);
|
|
373
|
+
const language = opts.language || 'en';
|
|
374
|
+
const invitationToken = opts.invitationToken || 'enjoy';
|
|
375
|
+
|
|
376
|
+
let hosting = opts.hosting;
|
|
377
|
+
if (hosting === 'auto') {
|
|
378
|
+
const flat = await this.flatHostings();
|
|
379
|
+
const first = flat.find(h => h.available);
|
|
380
|
+
if (!first) {
|
|
381
|
+
throw new PryvError(
|
|
382
|
+
'createUser({ hosting: "auto" }): no hosting flagged available'
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
hosting = first.key;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let url, payload;
|
|
389
|
+
if (isModern) {
|
|
390
|
+
url = serviceInfo.register + 'users';
|
|
391
|
+
payload = {
|
|
392
|
+
appId: opts.appId,
|
|
393
|
+
username: opts.username,
|
|
394
|
+
password: opts.password,
|
|
395
|
+
email: opts.email,
|
|
396
|
+
hosting,
|
|
397
|
+
language,
|
|
398
|
+
invitationToken
|
|
399
|
+
};
|
|
400
|
+
if (opts.referer != null) payload.referer = opts.referer;
|
|
401
|
+
} else {
|
|
402
|
+
url = serviceInfo.register + 'user';
|
|
403
|
+
payload = {
|
|
404
|
+
appid: opts.appId,
|
|
405
|
+
username: opts.username,
|
|
406
|
+
password: opts.password,
|
|
407
|
+
email: opts.email,
|
|
408
|
+
hosting,
|
|
409
|
+
languageCode: language,
|
|
410
|
+
invitationtoken: invitationToken
|
|
411
|
+
};
|
|
412
|
+
if (opts.referer != null) payload.referer = opts.referer;
|
|
413
|
+
}
|
|
414
|
+
const { response, body } = await utils.fetchPost(url, payload);
|
|
415
|
+
if (!response.ok) throw PryvError.fromApiResponse(response, body);
|
|
416
|
+
const apiEndpoint = await this.apiEndpointFor(opts.username);
|
|
417
|
+
return { username: opts.username, apiEndpoint };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Trigger a password-reset email for the given user.
|
|
422
|
+
* Pre-auth — no token required.
|
|
423
|
+
*
|
|
424
|
+
* @param {string} userId
|
|
425
|
+
* @param {string} appId
|
|
426
|
+
* @returns {Promise<void>}
|
|
427
|
+
* @throws {PryvError} on 4xx/5xx
|
|
428
|
+
*/
|
|
429
|
+
async requestPasswordReset (userId, appId) {
|
|
430
|
+
if (!userId || !appId) {
|
|
431
|
+
throw new PryvError('requestPasswordReset requires userId and appId');
|
|
432
|
+
}
|
|
433
|
+
const url = await this.apiEndpointFor(userId) +
|
|
434
|
+
'account/request-password-reset';
|
|
435
|
+
const { response, body } = await utils.fetchPost(url, {
|
|
436
|
+
appId,
|
|
437
|
+
username: userId
|
|
438
|
+
});
|
|
439
|
+
if (!response.ok) throw PryvError.fromApiResponse(response, body);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Start an access-request flow. Posts to the platform's auth endpoint
|
|
444
|
+
* (`serviceInfo.access`) and returns the envelope the consumer needs to
|
|
445
|
+
* present an approve-link to the user and poll for completion.
|
|
446
|
+
*
|
|
447
|
+
* `Browser.setupAuth` already wraps this for the high-level browser flow.
|
|
448
|
+
* Use this method when you're a non-browser caller (CLI, native app, bot)
|
|
449
|
+
* or building your own UI on top.
|
|
450
|
+
*
|
|
451
|
+
* @param {Object} authRequest - The auth-request body
|
|
452
|
+
* @param {string} authRequest.requestingAppId
|
|
453
|
+
* @param {Array<{ streamId: string, level: string, defaultName: string }>} authRequest.requestedPermissions
|
|
454
|
+
* @param {string} [authRequest.languageCode='en']
|
|
455
|
+
* @param {string|boolean} [authRequest.returnUrl]
|
|
456
|
+
* @param {string} [authRequest.referer]
|
|
457
|
+
* @param {Object} [authRequest.clientData]
|
|
458
|
+
* @param {string} [authRequest.deviceName]
|
|
459
|
+
* @param {number} [authRequest.expireAfter]
|
|
460
|
+
* @returns {Promise<{ key: string, authUrl: string, poll: string, pollRateMs: number }>}
|
|
461
|
+
* @throws {PryvError} on non-2xx
|
|
462
|
+
*/
|
|
463
|
+
async startAccessRequest (authRequest) {
|
|
464
|
+
if (!authRequest || !authRequest.requestingAppId) {
|
|
465
|
+
throw new PryvError(
|
|
466
|
+
'startAccessRequest requires authRequest.requestingAppId'
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
const serviceInfo = await this.info();
|
|
470
|
+
const { response, body } = await utils.fetchPost(
|
|
471
|
+
serviceInfo.access,
|
|
472
|
+
authRequest
|
|
473
|
+
);
|
|
474
|
+
if (!response.ok) throw PryvError.fromApiResponse(response, body);
|
|
475
|
+
if (!body || !body.key || !body.poll) {
|
|
476
|
+
throw new PryvError(
|
|
477
|
+
'Invalid access-request response: ' + JSON.stringify(body)
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
key: body.key,
|
|
482
|
+
authUrl: body.authUrl || body.url,
|
|
483
|
+
poll: body.poll,
|
|
484
|
+
pollRateMs: body.poll_rate_ms != null ? body.poll_rate_ms : body.pollRateMs
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Poll an in-progress access request once. Accepts either:
|
|
490
|
+
* - a `key` returned by `startAccessRequest` (poll URL is built from
|
|
491
|
+
* `serviceInfo.access + key`)
|
|
492
|
+
* - a full poll URL (use as-is — recommended, since the server-issued
|
|
493
|
+
* URL is canonical and may include a different subdomain).
|
|
494
|
+
*
|
|
495
|
+
* Returns the raw body. Inspect `body.status` to drive the flow:
|
|
496
|
+
* - `'NEED_SIGNIN'` → user has not interacted yet; keep polling.
|
|
497
|
+
* - `'ACCEPTED'` → `body.apiEndpoint` + `body.username` + `body.token` are set.
|
|
498
|
+
* - `'REFUSED'` → user declined.
|
|
499
|
+
*
|
|
500
|
+
* @param {string} keyOrPollUrl
|
|
501
|
+
* @returns {Promise<Object>}
|
|
502
|
+
* @throws {PryvError} on transport errors or non-2xx-and-not-403-REFUSED
|
|
503
|
+
*/
|
|
504
|
+
async pollAccessRequest (keyOrPollUrl) {
|
|
505
|
+
if (!keyOrPollUrl) {
|
|
506
|
+
throw new PryvError('pollAccessRequest requires a key or poll URL');
|
|
507
|
+
}
|
|
508
|
+
let pollUrl = keyOrPollUrl;
|
|
509
|
+
if (!/^https?:\/\//.test(keyOrPollUrl)) {
|
|
510
|
+
const serviceInfo = await this.info();
|
|
511
|
+
pollUrl = serviceInfo.access + keyOrPollUrl;
|
|
512
|
+
}
|
|
513
|
+
const { response, body } = await utils.fetchGet(pollUrl);
|
|
514
|
+
// 403 with status=REFUSED is the canonical "user declined" terminal
|
|
515
|
+
// state — treat as a successful poll, not an error (matches the
|
|
516
|
+
// behaviour of `Auth/AuthController.js`).
|
|
517
|
+
if (response.status === 403 && body && body.status === 'REFUSED') {
|
|
518
|
+
return body;
|
|
519
|
+
}
|
|
520
|
+
if (!response.ok) throw PryvError.fromApiResponse(response, body);
|
|
521
|
+
return body;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Set a new password using a reset token (from the reset email).
|
|
526
|
+
* Pre-auth — no login token required.
|
|
527
|
+
*
|
|
528
|
+
* @param {string} userId
|
|
529
|
+
* @param {string} newPassword
|
|
530
|
+
* @param {string} resetToken
|
|
531
|
+
* @param {string} appId
|
|
532
|
+
* @returns {Promise<void>}
|
|
533
|
+
* @throws {PryvError} on `unknown-or-expired-reset-token`, weak password, etc.
|
|
534
|
+
*/
|
|
535
|
+
async resetPassword (userId, newPassword, resetToken, appId) {
|
|
536
|
+
if (!userId || !newPassword || !resetToken || !appId) {
|
|
537
|
+
throw new PryvError(
|
|
538
|
+
'resetPassword requires userId, newPassword, resetToken, appId'
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
const url = await this.apiEndpointFor(userId) + 'account/reset-password';
|
|
542
|
+
const { response, body } = await utils.fetchPost(url, {
|
|
543
|
+
username: userId,
|
|
544
|
+
newPassword,
|
|
545
|
+
resetToken,
|
|
546
|
+
appId
|
|
547
|
+
});
|
|
548
|
+
if (!response.ok) throw PryvError.fromApiResponse(response, body);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Detect whether the platform's service-info `version` supports the modern
|
|
554
|
+
* camelCase register endpoint (`POST /users`). v2 service-register routes
|
|
555
|
+
* both, but v1 platforms only accept the mixed-case `POST /user`.
|
|
556
|
+
*
|
|
557
|
+
* @param {string|undefined} version - service-info `version` field
|
|
558
|
+
* @returns {boolean}
|
|
559
|
+
*/
|
|
560
|
+
function supportsCamelCaseRegister (version) {
|
|
561
|
+
if (!version || typeof version !== 'string') return true; // optimistic: assume v2+
|
|
562
|
+
const m = /^(\d+)\.(\d+)/.exec(version);
|
|
563
|
+
if (!m) return true;
|
|
564
|
+
const major = parseInt(m[1], 10);
|
|
565
|
+
const minor = parseInt(m[2], 10);
|
|
566
|
+
if (major >= 2) return true;
|
|
567
|
+
if (major === 1 && minor >= 6) return true;
|
|
568
|
+
return false;
|
|
199
569
|
}
|
|
200
570
|
|
|
201
571
|
module.exports = Service;
|
package/src/index.d.ts
CHANGED
|
@@ -166,14 +166,101 @@ declare module 'pryv' {
|
|
|
166
166
|
|
|
167
167
|
/**
|
|
168
168
|
* Custom error class for Pryv library errors.
|
|
169
|
-
*
|
|
169
|
+
*
|
|
170
|
+
* - `innerObject`: legacy field, set by the 2-arg constructor.
|
|
171
|
+
* - `id` / `status` / `response`: structured fields populated by
|
|
172
|
+
* `PryvError.fromApiResponse(response, body)` for errors that came
|
|
173
|
+
* from a Pryv API call. All three are `undefined` for legacy errors.
|
|
170
174
|
*/
|
|
171
175
|
export class PryvError extends globalThis.Error {
|
|
172
176
|
constructor(message: string, innerObject?: globalThis.Error | object);
|
|
173
177
|
name: 'PryvError';
|
|
174
178
|
innerObject?: globalThis.Error | object;
|
|
179
|
+
id?: string;
|
|
180
|
+
status?: number;
|
|
181
|
+
response?: { body: any; status: number };
|
|
182
|
+
static fromApiResponse(response: Response, body?: any): PryvError;
|
|
175
183
|
}
|
|
176
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Thrown by `Service.login` when the platform returned `{ mfaToken }`
|
|
187
|
+
* instead of `{ token }`. Carries the `mfaToken` so the consumer can
|
|
188
|
+
* call `Service.mfaVerify(userId, err.mfaToken, code)` after prompting
|
|
189
|
+
* the user for their SMS code.
|
|
190
|
+
*/
|
|
191
|
+
export class MfaRequiredError extends PryvError {
|
|
192
|
+
constructor(mfaToken: string, response: Response, body?: any);
|
|
193
|
+
name: 'MfaRequiredError';
|
|
194
|
+
mfaToken: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Catalogue of Pryv API error ids (mirrors open-pryv.io ErrorIds.js). */
|
|
198
|
+
export const ERRORS: {
|
|
199
|
+
readonly API_UNAVAILABLE: 'api-unavailable';
|
|
200
|
+
readonly CORRUPTED_DATA: 'corrupted-data';
|
|
201
|
+
readonly FORBIDDEN: 'forbidden';
|
|
202
|
+
readonly INVALID_ACCESS_TOKEN: 'invalid-access-token';
|
|
203
|
+
readonly INVALID_CREDENTIALS: 'invalid-credentials';
|
|
204
|
+
readonly UNSUPPORTED_OPERATION: 'unsupported-operation';
|
|
205
|
+
readonly INVALID_EVENT_TYPE: 'invalid-event-type';
|
|
206
|
+
readonly INVALID_ITEM_ID: 'invalid-item-id';
|
|
207
|
+
readonly INVALID_METHOD: 'invalid-method';
|
|
208
|
+
readonly INVALID_OPERATION: 'invalid-operation';
|
|
209
|
+
readonly INVALID_PARAMETERS_FORMAT: 'invalid-parameters-format';
|
|
210
|
+
readonly INVALID_REQUEST_STRUCTURE: 'invalid-request-structure';
|
|
211
|
+
readonly ITEM_ALREADY_EXISTS: 'item-already-exists';
|
|
212
|
+
readonly MISSING_HEADER: 'missing-header';
|
|
213
|
+
readonly UNEXPECTED_ERROR: 'unexpected-error';
|
|
214
|
+
readonly UNKNOWN_REFERENCED_RESOURCE: 'unknown-referenced-resource';
|
|
215
|
+
readonly UNKNOWN_RESOURCE: 'unknown-resource';
|
|
216
|
+
readonly UNSUPPORTED_CONTENT_TYPE: 'unsupported-content-type';
|
|
217
|
+
readonly TOO_MANY_RESULTS: 'too-many-results';
|
|
218
|
+
readonly GONE: 'removed-method';
|
|
219
|
+
readonly UNAVAILABLE_METHOD: 'unavailable-method';
|
|
220
|
+
readonly INVALID_INVITATION_TOKEN: 'invitationToken-invalid';
|
|
221
|
+
readonly INVALID_USERNAME: 'username-invalid';
|
|
222
|
+
readonly USERNAME_REQUIRED: 'username-required';
|
|
223
|
+
readonly INVALID_EMAIL: 'email-invalid';
|
|
224
|
+
readonly INVALID_LANGUAGE: 'language-invalid';
|
|
225
|
+
readonly INVALID_APP_ID: 'appid-invalid';
|
|
226
|
+
readonly INVALID_PASSWORD: 'password-invalid';
|
|
227
|
+
readonly INVALID_REFERER: 'referer-invalid';
|
|
228
|
+
readonly EMAIL_REQUIRED: 'email-required';
|
|
229
|
+
readonly PASSWORD_REQUIRED: 'password-required';
|
|
230
|
+
readonly MISSING_REQUIRED_FIELD: 'missing-required-field';
|
|
231
|
+
readonly NEW_PASSWORD_FIELD_IS_REQUIRED: 'newPassword-required';
|
|
232
|
+
readonly DENIED_STREAM_ACCESS: 'denied-stream-access';
|
|
233
|
+
readonly TOO_HIGH_ACCESS_FOR_SYSTEM_STREAMS: 'too-high-access-for-account-stream';
|
|
234
|
+
readonly FORBIDDEN_MULTIPLE_ACCOUNT_STREAMS: 'forbidden-multiple-account-streams-events';
|
|
235
|
+
readonly FORBIDDEN_ACCOUNT_EVENT_MODIFICATION: 'forbidden-none-editable-account-streams';
|
|
236
|
+
readonly FORBIDDEN_TO_CHANGE_ACCOUNT_STREAM_ID: 'forbidden-change-account-streams-id';
|
|
237
|
+
readonly FORBIDDEN_TO_EDIT_NONEDITABLE_ACCOUNT_FIELDS: 'forbidden-to-edit-noneditable-account-fields';
|
|
238
|
+
readonly UNKNOWN_USER: 'unknown-user';
|
|
239
|
+
readonly UNKNOWN_EMAIL: 'unknown-email';
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export type CreateUserOptions = {
|
|
243
|
+
username: string;
|
|
244
|
+
password: string;
|
|
245
|
+
email: string;
|
|
246
|
+
/** Hosting key (from `flatHostings()`) or the literal `'auto'` */
|
|
247
|
+
hosting: string;
|
|
248
|
+
appId: string;
|
|
249
|
+
language?: string;
|
|
250
|
+
invitationToken?: string;
|
|
251
|
+
referer?: string;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export type HostingItem = {
|
|
255
|
+
key: string;
|
|
256
|
+
name: string;
|
|
257
|
+
description: string;
|
|
258
|
+
region: string;
|
|
259
|
+
zone: string;
|
|
260
|
+
availableCore: string;
|
|
261
|
+
available: boolean;
|
|
262
|
+
};
|
|
263
|
+
|
|
177
264
|
export type StreamsQuery = {
|
|
178
265
|
any?: Identifier[];
|
|
179
266
|
all?: Identifier[];
|
|
@@ -736,6 +823,24 @@ declare module 'pryv' {
|
|
|
736
823
|
supportsHF(): Promise<boolean>;
|
|
737
824
|
isDnsLess(): Promise<boolean>;
|
|
738
825
|
|
|
826
|
+
userExists(userId: string): Promise<boolean>;
|
|
827
|
+
userIdForEmail(email: string): Promise<string | null>;
|
|
828
|
+
availableHostings(): Promise<any>;
|
|
829
|
+
flatHostings(): Promise<HostingItem[]>;
|
|
830
|
+
createUser(opts: CreateUserOptions): Promise<{ username: string; apiEndpoint: string }>;
|
|
831
|
+
requestPasswordReset(userId: string, appId: string): Promise<void>;
|
|
832
|
+
resetPassword(userId: string, newPassword: string, resetToken: string, appId: string): Promise<void>;
|
|
833
|
+
mfaChallenge(userId: string, mfaToken: string): Promise<void>;
|
|
834
|
+
mfaVerify(userId: string, mfaToken: string, code: string): Promise<Connection>;
|
|
835
|
+
|
|
836
|
+
startAccessRequest(authRequest: AuthSettings['authRequest']): Promise<{
|
|
837
|
+
key: string;
|
|
838
|
+
authUrl: string;
|
|
839
|
+
poll: string;
|
|
840
|
+
pollRateMs: number;
|
|
841
|
+
}>;
|
|
842
|
+
pollAccessRequest(keyOrPollUrl: string): Promise<any>;
|
|
843
|
+
|
|
739
844
|
static buildAPIEndpoint(
|
|
740
845
|
serviceInfo: ServiceInfo,
|
|
741
846
|
username: string,
|
|
@@ -1002,6 +1107,8 @@ declare module 'pryv' {
|
|
|
1002
1107
|
getQueryParamsFromURL(url: string): KeyValue;
|
|
1003
1108
|
};
|
|
1004
1109
|
PryvError: typeof PryvError;
|
|
1110
|
+
MfaRequiredError: typeof MfaRequiredError;
|
|
1111
|
+
ERRORS: typeof ERRORS;
|
|
1005
1112
|
version: version;
|
|
1006
1113
|
};
|
|
1007
1114
|
|
package/src/index.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* @property {pryv.Connection} Connection - To interact with an individual's (user) data set
|
|
10
10
|
* @property {pryv.Browser} Browser - Browser Tools - Access request helpers and visuals (button)
|
|
11
11
|
* @property {pryv.utils} utils - Exposes some utils for HTTP calls and tools to manipulate Pryv's API endpoints
|
|
12
|
-
* @property {pryv.PryvError} PryvError - Custom error class with innerObject
|
|
12
|
+
* @property {pryv.PryvError} PryvError - Custom error class with innerObject + structured API-error fields
|
|
13
|
+
* @property {Object} ERRORS - Catalogue of Pryv API error ids (mirrors open-pryv.io/components/errors)
|
|
13
14
|
*/
|
|
14
15
|
module.exports = {
|
|
15
16
|
Service: require('./Service'),
|
|
@@ -18,5 +19,7 @@ module.exports = {
|
|
|
18
19
|
Browser: require('./Browser'),
|
|
19
20
|
utils: require('./utils'),
|
|
20
21
|
PryvError: require('./lib/PryvError'),
|
|
22
|
+
MfaRequiredError: require('./lib/MfaRequiredError'),
|
|
23
|
+
ERRORS: require('./lib/errorIds'),
|
|
21
24
|
version: require('../package.json').version
|
|
22
25
|
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const PryvError = require('./PryvError');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Thrown by `Service.login` when the platform replied with `{ mfaToken }`
|
|
10
|
+
* instead of `{ token }`. Consumers catch this, prompt the user for the
|
|
11
|
+
* SMS code, then call `Service.mfaVerify(userId, err.mfaToken, code)`.
|
|
12
|
+
*
|
|
13
|
+
* try { conn = await service.login(u, p, app) }
|
|
14
|
+
* catch (err) {
|
|
15
|
+
* if (err instanceof MfaRequiredError) {
|
|
16
|
+
* const code = await prompt()
|
|
17
|
+
* conn = await service.mfaVerify(u, err.mfaToken, code)
|
|
18
|
+
* } else { throw err }
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* @extends PryvError
|
|
22
|
+
*/
|
|
23
|
+
class MfaRequiredError extends PryvError {
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} mfaToken - The token returned by the API (use with mfa.challenge / mfa.verify)
|
|
26
|
+
* @param {Response} response - The fetch Response object
|
|
27
|
+
* @param {Object} [body] - Parsed JSON body
|
|
28
|
+
*/
|
|
29
|
+
constructor (mfaToken, response, body) {
|
|
30
|
+
const apiErr = body && body.error;
|
|
31
|
+
const message = (apiErr && apiErr.message) || 'MFA required';
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = 'MfaRequiredError';
|
|
34
|
+
/** @type {string} */
|
|
35
|
+
this.mfaToken = mfaToken;
|
|
36
|
+
this.id = (apiErr && apiErr.id) || 'mfa-required';
|
|
37
|
+
this.status = response && response.status;
|
|
38
|
+
this.response = { body, status: response && response.status };
|
|
39
|
+
|
|
40
|
+
if (Error.captureStackTrace) {
|
|
41
|
+
Error.captureStackTrace(this, MfaRequiredError);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = MfaRequiredError;
|
package/src/lib/PryvError.js
CHANGED
|
@@ -5,26 +5,60 @@
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Custom error class for Pryv library errors.
|
|
8
|
-
*
|
|
8
|
+
*
|
|
9
|
+
* Two construction patterns are supported (additive — both stay valid):
|
|
10
|
+
*
|
|
11
|
+
* // Legacy: wrap an underlying error or value
|
|
12
|
+
* throw new PryvError('Failed to do X', innerError)
|
|
13
|
+
*
|
|
14
|
+
* // Structured: carry the API error id, HTTP status, and raw response
|
|
15
|
+
* throw PryvError.fromApiResponse(response, body)
|
|
16
|
+
*
|
|
17
|
+
* Structured fields (`id`, `status`, `response`) are `undefined` unless set
|
|
18
|
+
* via the static factory or assigned post-hoc.
|
|
19
|
+
*
|
|
9
20
|
* @extends Error
|
|
10
21
|
*/
|
|
11
22
|
class PryvError extends Error {
|
|
12
23
|
/**
|
|
13
|
-
* Create a PryvError
|
|
14
24
|
* @param {string} message - Error message
|
|
15
|
-
* @param {Error|Object} [innerObject] -
|
|
25
|
+
* @param {Error|Object} [innerObject] - Underlying error or value
|
|
16
26
|
*/
|
|
17
27
|
constructor (message, innerObject) {
|
|
18
28
|
super(message);
|
|
19
29
|
this.name = 'PryvError';
|
|
20
30
|
/** @type {Error|Object|undefined} */
|
|
21
31
|
this.innerObject = innerObject;
|
|
32
|
+
/** @type {string|undefined} Pryv API error id, e.g. `'unknown-user'` */
|
|
33
|
+
this.id = undefined;
|
|
34
|
+
/** @type {number|undefined} HTTP status that produced this error */
|
|
35
|
+
this.status = undefined;
|
|
36
|
+
/** @type {{ body: any, status: number }|undefined} Raw response */
|
|
37
|
+
this.response = undefined;
|
|
22
38
|
|
|
23
|
-
// Maintains proper stack trace for where error was thrown (only in V8)
|
|
24
39
|
if (Error.captureStackTrace) {
|
|
25
40
|
Error.captureStackTrace(this, PryvError);
|
|
26
41
|
}
|
|
27
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a PryvError from a fetch Response and its parsed JSON body.
|
|
46
|
+
* Pulls `id` and `message` from `body.error` when present (Pryv API shape).
|
|
47
|
+
*
|
|
48
|
+
* @param {Response} response - The fetch Response object
|
|
49
|
+
* @param {Object} [body] - Parsed JSON body
|
|
50
|
+
* @returns {PryvError}
|
|
51
|
+
*/
|
|
52
|
+
static fromApiResponse (response, body) {
|
|
53
|
+
const apiErr = body && body.error;
|
|
54
|
+
const message = (apiErr && apiErr.message) ||
|
|
55
|
+
`Pryv API error (HTTP ${response.status})`;
|
|
56
|
+
const err = new PryvError(message);
|
|
57
|
+
err.id = apiErr && apiErr.id;
|
|
58
|
+
err.status = response.status;
|
|
59
|
+
err.response = { body, status: response.status };
|
|
60
|
+
return err;
|
|
61
|
+
}
|
|
28
62
|
}
|
|
29
63
|
|
|
30
64
|
module.exports = PryvError;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Catalogue of Pryv API error ids.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors `open-pryv.io/components/errors/src/ErrorIds.js`. Use these
|
|
10
|
+
* constants instead of hardcoding error-id strings:
|
|
11
|
+
*
|
|
12
|
+
* if (err instanceof PryvError && err.id === pryv.ERRORS.UNKNOWN_USER) { … }
|
|
13
|
+
*
|
|
14
|
+
* Adding a new id here is safe; renaming or removing one is a breaking
|
|
15
|
+
* change for consumers using the constant.
|
|
16
|
+
*/
|
|
17
|
+
const ERRORS = Object.freeze({
|
|
18
|
+
API_UNAVAILABLE: 'api-unavailable',
|
|
19
|
+
CORRUPTED_DATA: 'corrupted-data',
|
|
20
|
+
FORBIDDEN: 'forbidden',
|
|
21
|
+
INVALID_ACCESS_TOKEN: 'invalid-access-token',
|
|
22
|
+
INVALID_CREDENTIALS: 'invalid-credentials',
|
|
23
|
+
UNSUPPORTED_OPERATION: 'unsupported-operation',
|
|
24
|
+
INVALID_EVENT_TYPE: 'invalid-event-type',
|
|
25
|
+
INVALID_ITEM_ID: 'invalid-item-id',
|
|
26
|
+
INVALID_METHOD: 'invalid-method',
|
|
27
|
+
INVALID_OPERATION: 'invalid-operation',
|
|
28
|
+
INVALID_PARAMETERS_FORMAT: 'invalid-parameters-format',
|
|
29
|
+
INVALID_REQUEST_STRUCTURE: 'invalid-request-structure',
|
|
30
|
+
ITEM_ALREADY_EXISTS: 'item-already-exists',
|
|
31
|
+
MISSING_HEADER: 'missing-header',
|
|
32
|
+
UNEXPECTED_ERROR: 'unexpected-error',
|
|
33
|
+
UNKNOWN_REFERENCED_RESOURCE: 'unknown-referenced-resource',
|
|
34
|
+
UNKNOWN_RESOURCE: 'unknown-resource',
|
|
35
|
+
UNSUPPORTED_CONTENT_TYPE: 'unsupported-content-type',
|
|
36
|
+
TOO_MANY_RESULTS: 'too-many-results',
|
|
37
|
+
GONE: 'removed-method',
|
|
38
|
+
UNAVAILABLE_METHOD: 'unavailable-method',
|
|
39
|
+
|
|
40
|
+
// Registration / unique-field validation
|
|
41
|
+
INVALID_INVITATION_TOKEN: 'invitationToken-invalid',
|
|
42
|
+
INVALID_USERNAME: 'username-invalid',
|
|
43
|
+
USERNAME_REQUIRED: 'username-required',
|
|
44
|
+
INVALID_EMAIL: 'email-invalid',
|
|
45
|
+
INVALID_LANGUAGE: 'language-invalid',
|
|
46
|
+
INVALID_APP_ID: 'appid-invalid',
|
|
47
|
+
INVALID_PASSWORD: 'password-invalid',
|
|
48
|
+
INVALID_REFERER: 'referer-invalid',
|
|
49
|
+
EMAIL_REQUIRED: 'email-required',
|
|
50
|
+
PASSWORD_REQUIRED: 'password-required',
|
|
51
|
+
MISSING_REQUIRED_FIELD: 'missing-required-field',
|
|
52
|
+
NEW_PASSWORD_FIELD_IS_REQUIRED: 'newPassword-required',
|
|
53
|
+
|
|
54
|
+
// Account-stream / system-stream protections
|
|
55
|
+
DENIED_STREAM_ACCESS: 'denied-stream-access',
|
|
56
|
+
TOO_HIGH_ACCESS_FOR_SYSTEM_STREAMS: 'too-high-access-for-account-stream',
|
|
57
|
+
FORBIDDEN_MULTIPLE_ACCOUNT_STREAMS: 'forbidden-multiple-account-streams-events',
|
|
58
|
+
FORBIDDEN_ACCOUNT_EVENT_MODIFICATION: 'forbidden-none-editable-account-streams',
|
|
59
|
+
FORBIDDEN_TO_CHANGE_ACCOUNT_STREAM_ID: 'forbidden-change-account-streams-id',
|
|
60
|
+
FORBIDDEN_TO_EDIT_NONEDITABLE_ACCOUNT_FIELDS: 'forbidden-to-edit-noneditable-account-fields',
|
|
61
|
+
|
|
62
|
+
// Pre-auth lookups (returned by `/reg/:email/uid` and similar)
|
|
63
|
+
UNKNOWN_USER: 'unknown-user',
|
|
64
|
+
UNKNOWN_EMAIL: 'unknown-email'
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
module.exports = ERRORS;
|
package/test/PryvError.test.js
CHANGED
|
@@ -37,4 +37,38 @@ describe('[PERX] PryvError', function () {
|
|
|
37
37
|
const error = new PryvError('No inner');
|
|
38
38
|
expect(error.innerObject).to.be.undefined;
|
|
39
39
|
});
|
|
40
|
+
|
|
41
|
+
it('[PERF] structured fields default to undefined for legacy constructor', function () {
|
|
42
|
+
const error = new PryvError('Test');
|
|
43
|
+
expect(error.id).to.be.undefined;
|
|
44
|
+
expect(error.status).to.be.undefined;
|
|
45
|
+
expect(error.response).to.be.undefined;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('[PERG] fromApiResponse populates id/status/response from API body', function () {
|
|
49
|
+
const fakeResponse = { status: 404 };
|
|
50
|
+
const body = { error: { id: 'unknown-user', message: 'Unknown user' } };
|
|
51
|
+
const error = PryvError.fromApiResponse(fakeResponse, body);
|
|
52
|
+
expect(error).to.be.instanceOf(PryvError);
|
|
53
|
+
expect(error.id).to.equal('unknown-user');
|
|
54
|
+
expect(error.status).to.equal(404);
|
|
55
|
+
expect(error.response).to.deep.equal({ body, status: 404 });
|
|
56
|
+
expect(error.message).to.equal('Unknown user');
|
|
57
|
+
expect(error.innerObject).to.be.undefined;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('[PERH] fromApiResponse falls back to a generic message when body has no error', function () {
|
|
61
|
+
const fakeResponse = { status: 500 };
|
|
62
|
+
const error = PryvError.fromApiResponse(fakeResponse, { foo: 'bar' });
|
|
63
|
+
expect(error.id).to.be.undefined;
|
|
64
|
+
expect(error.status).to.equal(500);
|
|
65
|
+
expect(error.message).to.match(/HTTP 500/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('[PERI] fromApiResponse handles null/undefined body without throwing', function () {
|
|
69
|
+
const fakeResponse = { status: 502 };
|
|
70
|
+
const error = PryvError.fromApiResponse(fakeResponse, undefined);
|
|
71
|
+
expect(error.status).to.equal(502);
|
|
72
|
+
expect(error.response).to.deep.equal({ body: undefined, status: 502 });
|
|
73
|
+
});
|
|
40
74
|
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, before, expect, pryv, testData */
|
|
6
|
+
|
|
7
|
+
describe('[ARQX] Service access-request init', function () {
|
|
8
|
+
let service;
|
|
9
|
+
|
|
10
|
+
before(async function () {
|
|
11
|
+
this.timeout(15000);
|
|
12
|
+
await testData.prepare();
|
|
13
|
+
service = new pryv.Service(testData.serviceInfoUrl);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('[ASTX] Service.startAccessRequest', function () {
|
|
17
|
+
it('[ASTA] rejects when requestingAppId is missing', async function () {
|
|
18
|
+
let caught;
|
|
19
|
+
try { await service.startAccessRequest({}); } catch (e) { caught = e; }
|
|
20
|
+
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('[ASTB] returns { key, authUrl, poll, pollRateMs }', async function () {
|
|
24
|
+
this.timeout(15000);
|
|
25
|
+
const env = await service.startAccessRequest({
|
|
26
|
+
requestingAppId: 'jslib-test',
|
|
27
|
+
requestedPermissions: [{
|
|
28
|
+
streamId: 'data',
|
|
29
|
+
level: 'read',
|
|
30
|
+
defaultName: 'Test'
|
|
31
|
+
}]
|
|
32
|
+
});
|
|
33
|
+
expect(env.key).to.be.a('string');
|
|
34
|
+
expect(env.authUrl).to.be.a('string');
|
|
35
|
+
expect(env.poll).to.be.a('string');
|
|
36
|
+
expect(env.poll).to.match(/^https?:\/\//);
|
|
37
|
+
expect(env.pollRateMs).to.be.a('number');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('[APRX] Service.pollAccessRequest', function () {
|
|
42
|
+
it('[APRA] rejects when key is missing', async function () {
|
|
43
|
+
let caught;
|
|
44
|
+
try { await service.pollAccessRequest(); } catch (e) { caught = e; }
|
|
45
|
+
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('[APRB] polling a fresh request returns NEED_SIGNIN', async function () {
|
|
49
|
+
this.timeout(15000);
|
|
50
|
+
const env = await service.startAccessRequest({
|
|
51
|
+
requestingAppId: 'jslib-test',
|
|
52
|
+
requestedPermissions: [{
|
|
53
|
+
streamId: 'data',
|
|
54
|
+
level: 'read',
|
|
55
|
+
defaultName: 'Test'
|
|
56
|
+
}]
|
|
57
|
+
});
|
|
58
|
+
const state = await service.pollAccessRequest(env.poll);
|
|
59
|
+
expect(state).to.exist;
|
|
60
|
+
expect(state.status).to.equal('NEED_SIGNIN');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('[APRC] accepts a bare key (no scheme) and builds the URL', async function () {
|
|
64
|
+
this.timeout(15000);
|
|
65
|
+
const env = await service.startAccessRequest({
|
|
66
|
+
requestingAppId: 'jslib-test',
|
|
67
|
+
requestedPermissions: [{
|
|
68
|
+
streamId: 'data',
|
|
69
|
+
level: 'read',
|
|
70
|
+
defaultName: 'Test'
|
|
71
|
+
}]
|
|
72
|
+
});
|
|
73
|
+
const state = await service.pollAccessRequest(env.key);
|
|
74
|
+
expect(state).to.exist;
|
|
75
|
+
expect(state.status).to.equal('NEED_SIGNIN');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, before, expect, pryv, testData */
|
|
6
|
+
|
|
7
|
+
const { createId: cuid } = require('@paralleldrive/cuid2');
|
|
8
|
+
|
|
9
|
+
describe('[CRUX] Service.createUser', function () {
|
|
10
|
+
let service;
|
|
11
|
+
let hostingKey;
|
|
12
|
+
|
|
13
|
+
before(async function () {
|
|
14
|
+
this.timeout(15000);
|
|
15
|
+
await testData.prepare();
|
|
16
|
+
service = new pryv.Service(testData.serviceInfoUrl);
|
|
17
|
+
const serviceInfo = await service.info();
|
|
18
|
+
// Discover any available hosting key — equivalent to what the deferred
|
|
19
|
+
// 1.4 `flatHostings()` will eventually do.
|
|
20
|
+
const res = await fetch(serviceInfo.register + 'hostings');
|
|
21
|
+
const tree = await res.json();
|
|
22
|
+
hostingKey = findFirstAvailableHostingKey(tree);
|
|
23
|
+
if (!hostingKey) this.skip();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('[CRUA] rejects when required fields are missing', async function () {
|
|
27
|
+
let caught;
|
|
28
|
+
try {
|
|
29
|
+
await service.createUser({ username: 'foo' });
|
|
30
|
+
} catch (e) {
|
|
31
|
+
caught = e;
|
|
32
|
+
}
|
|
33
|
+
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
34
|
+
expect(caught.message).to.match(/createUser requires/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('[CRUB] creates a fresh user and returns username + apiEndpoint', async function () {
|
|
38
|
+
this.timeout(20000);
|
|
39
|
+
const username = 'jslibcr' + cuid().slice(0, 8);
|
|
40
|
+
const result = await service.createUser({
|
|
41
|
+
username,
|
|
42
|
+
password: username + 'PASS!1',
|
|
43
|
+
email: username + '@example.com',
|
|
44
|
+
hosting: hostingKey,
|
|
45
|
+
appId: 'jslib-test',
|
|
46
|
+
language: 'en'
|
|
47
|
+
});
|
|
48
|
+
expect(result.username).to.equal(username);
|
|
49
|
+
expect(result.apiEndpoint).to.be.a('string');
|
|
50
|
+
expect(result.apiEndpoint).to.include(username);
|
|
51
|
+
|
|
52
|
+
// Sanity: the new user can be looked up
|
|
53
|
+
const exists = await service.userExists(username);
|
|
54
|
+
expect(exists).to.equal(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('[CRUC] throws PryvError when re-creating an existing user', async function () {
|
|
58
|
+
this.timeout(20000);
|
|
59
|
+
let caught;
|
|
60
|
+
try {
|
|
61
|
+
await service.createUser({
|
|
62
|
+
username: testData.username,
|
|
63
|
+
password: testData.password,
|
|
64
|
+
email: testData.username + '@pryv.io',
|
|
65
|
+
hosting: hostingKey,
|
|
66
|
+
appId: 'jslib-test'
|
|
67
|
+
});
|
|
68
|
+
} catch (e) {
|
|
69
|
+
caught = e;
|
|
70
|
+
}
|
|
71
|
+
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
72
|
+
expect(caught.status).to.be.gte(400);
|
|
73
|
+
// Server-side validation order varies; we don't pin the exact id.
|
|
74
|
+
expect(caught.response).to.have.property('status');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('[CRUD] hosting: "auto" picks the first available hosting', async function () {
|
|
78
|
+
this.timeout(20000);
|
|
79
|
+
const username = 'jsliba' + cuid().slice(0, 8);
|
|
80
|
+
const result = await service.createUser({
|
|
81
|
+
username,
|
|
82
|
+
password: username + 'PASS!1',
|
|
83
|
+
email: username + '@example.com',
|
|
84
|
+
hosting: 'auto',
|
|
85
|
+
appId: 'jslib-test'
|
|
86
|
+
});
|
|
87
|
+
expect(result.username).to.equal(username);
|
|
88
|
+
expect(result.apiEndpoint).to.be.a('string');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
function findFirstAvailableHostingKey (tree) {
|
|
93
|
+
const regions = (tree && tree.regions) || {};
|
|
94
|
+
for (const region of Object.values(regions)) {
|
|
95
|
+
const zones = region.zones || {};
|
|
96
|
+
for (const zone of Object.values(zones)) {
|
|
97
|
+
const hostings = zone.hostings || {};
|
|
98
|
+
for (const [key, h] of Object.entries(hostings)) {
|
|
99
|
+
if (h && h.available) return key;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, before, expect, pryv, testData */
|
|
6
|
+
|
|
7
|
+
describe('[HSTX] Service.availableHostings + flatHostings', function () {
|
|
8
|
+
let service;
|
|
9
|
+
|
|
10
|
+
before(async function () {
|
|
11
|
+
this.timeout(15000);
|
|
12
|
+
await testData.prepare();
|
|
13
|
+
service = new pryv.Service(testData.serviceInfoUrl);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('[HSTA] availableHostings() returns the raw regions tree', async function () {
|
|
17
|
+
this.timeout(15000);
|
|
18
|
+
const tree = await service.availableHostings();
|
|
19
|
+
expect(tree).to.be.an('object');
|
|
20
|
+
expect(tree.regions).to.be.an('object');
|
|
21
|
+
// At least one region with at least one zone with at least one hosting
|
|
22
|
+
const regions = Object.values(tree.regions);
|
|
23
|
+
expect(regions.length).to.be.gte(1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('[HSTB] flatHostings() returns a list with key/name/region/zone fields', async function () {
|
|
27
|
+
this.timeout(15000);
|
|
28
|
+
const list = await service.flatHostings();
|
|
29
|
+
expect(list).to.be.an('array');
|
|
30
|
+
expect(list.length).to.be.gte(1);
|
|
31
|
+
for (const item of list) {
|
|
32
|
+
expect(item.key).to.be.a('string');
|
|
33
|
+
expect(item.name).to.be.a('string');
|
|
34
|
+
expect(item.region).to.be.a('string');
|
|
35
|
+
expect(item.zone).to.be.a('string');
|
|
36
|
+
expect(item.availableCore).to.be.a('string');
|
|
37
|
+
expect(item.available).to.be.a('boolean');
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('[HSTC] flatHostings() shape matches availableHostings() leaf nodes', async function () {
|
|
42
|
+
this.timeout(15000);
|
|
43
|
+
const tree = await service.availableHostings();
|
|
44
|
+
const flat = await service.flatHostings();
|
|
45
|
+
let leafCount = 0;
|
|
46
|
+
for (const region of Object.values(tree.regions || {})) {
|
|
47
|
+
for (const zone of Object.values(region.zones || {})) {
|
|
48
|
+
leafCount += Object.keys(zone.hostings || {}).length;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
expect(flat.length).to.equal(leafCount);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, before, expect, pryv, testData */
|
|
6
|
+
|
|
7
|
+
const { createId: cuid } = require('@paralleldrive/cuid2');
|
|
8
|
+
|
|
9
|
+
// Note: testData.username is a non-MFA-protected test user, so we cannot
|
|
10
|
+
// exercise the success paths of mfaChallenge / mfaVerify against pryv.me.
|
|
11
|
+
// What we can verify:
|
|
12
|
+
// - argument validation
|
|
13
|
+
// - bogus mfaToken → PryvError on the wire
|
|
14
|
+
// - MfaRequiredError type + export shape
|
|
15
|
+
// The happy path is exercised end-to-end by open-pryv.io's MFA test suite
|
|
16
|
+
// (which spins up a fake SMS provider); replicating that here would require
|
|
17
|
+
// a mock layer the project doesn't ship.
|
|
18
|
+
|
|
19
|
+
describe('[MFLX] Service MFA', function () {
|
|
20
|
+
let service;
|
|
21
|
+
|
|
22
|
+
before(async function () {
|
|
23
|
+
this.timeout(15000);
|
|
24
|
+
await testData.prepare();
|
|
25
|
+
service = new pryv.Service(testData.serviceInfoUrl);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('[MCHX] Service.mfaChallenge', function () {
|
|
29
|
+
it('[MCHA] rejects when args are missing', async function () {
|
|
30
|
+
let caught;
|
|
31
|
+
try { await service.mfaChallenge(testData.username); } catch (e) { caught = e; }
|
|
32
|
+
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('[MCHB] throws PryvError on bogus mfaToken', async function () {
|
|
36
|
+
this.timeout(15000);
|
|
37
|
+
let caught;
|
|
38
|
+
try {
|
|
39
|
+
await service.mfaChallenge(testData.username, 'bogus-' + cuid().slice(0, 8));
|
|
40
|
+
} catch (e) { caught = e; }
|
|
41
|
+
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
42
|
+
expect(caught.status).to.be.gte(400);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('[MVRX] Service.mfaVerify', function () {
|
|
47
|
+
it('[MVRA] rejects when args are missing', async function () {
|
|
48
|
+
let caught;
|
|
49
|
+
try { await service.mfaVerify(testData.username, 'token'); } catch (e) { caught = e; }
|
|
50
|
+
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('[MVRB] throws PryvError on bogus mfaToken', async function () {
|
|
54
|
+
this.timeout(15000);
|
|
55
|
+
let caught;
|
|
56
|
+
try {
|
|
57
|
+
await service.mfaVerify(
|
|
58
|
+
testData.username,
|
|
59
|
+
'bogus-' + cuid().slice(0, 8),
|
|
60
|
+
'123456'
|
|
61
|
+
);
|
|
62
|
+
} catch (e) { caught = e; }
|
|
63
|
+
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
64
|
+
expect(caught.status).to.be.gte(400);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('[MERX] MfaRequiredError', function () {
|
|
69
|
+
it('[MERA] is exported on the package root and extends PryvError', function () {
|
|
70
|
+
expect(pryv.MfaRequiredError).to.be.a('function');
|
|
71
|
+
const err = new pryv.MfaRequiredError(
|
|
72
|
+
'tok-abc',
|
|
73
|
+
{ status: 200 },
|
|
74
|
+
{ mfaToken: 'tok-abc' }
|
|
75
|
+
);
|
|
76
|
+
expect(err).to.be.instanceOf(pryv.MfaRequiredError);
|
|
77
|
+
expect(err).to.be.instanceOf(pryv.PryvError);
|
|
78
|
+
expect(err.mfaToken).to.equal('tok-abc');
|
|
79
|
+
expect(err.id).to.equal('mfa-required');
|
|
80
|
+
expect(err.status).to.equal(200);
|
|
81
|
+
expect(err.name).to.equal('MfaRequiredError');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('[MERB] picks up id/message from API error body when provided', function () {
|
|
85
|
+
const err = new pryv.MfaRequiredError(
|
|
86
|
+
'tok-xyz',
|
|
87
|
+
{ status: 401 },
|
|
88
|
+
{ error: { id: 'custom-id', message: 'custom msg' } }
|
|
89
|
+
);
|
|
90
|
+
expect(err.id).to.equal('custom-id');
|
|
91
|
+
expect(err.message).to.equal('custom msg');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, before, expect, pryv, testData */
|
|
6
|
+
|
|
7
|
+
const { createId: cuid } = require('@paralleldrive/cuid2');
|
|
8
|
+
|
|
9
|
+
describe('[PWRX] Service.requestPasswordReset', function () {
|
|
10
|
+
let service;
|
|
11
|
+
|
|
12
|
+
before(async function () {
|
|
13
|
+
this.timeout(15000);
|
|
14
|
+
await testData.prepare();
|
|
15
|
+
service = new pryv.Service(testData.serviceInfoUrl);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('[PWRA] rejects when required args are missing', async function () {
|
|
19
|
+
let caught;
|
|
20
|
+
try {
|
|
21
|
+
await service.requestPasswordReset(testData.username);
|
|
22
|
+
} catch (e) {
|
|
23
|
+
caught = e;
|
|
24
|
+
}
|
|
25
|
+
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('[PWRB] either resolves or throws PryvError (server may require trusted appId)', async function () {
|
|
29
|
+
this.timeout(15000);
|
|
30
|
+
// Pryv platforms typically restrict password-reset to a trusted-appId
|
|
31
|
+
// allowlist (pryv.me does). On a permissive deployment this resolves;
|
|
32
|
+
// on a stricter one we expect a structured PryvError, not a raw throw.
|
|
33
|
+
let caught;
|
|
34
|
+
try {
|
|
35
|
+
await service.requestPasswordReset(testData.username, 'jslib-test');
|
|
36
|
+
} catch (e) {
|
|
37
|
+
caught = e;
|
|
38
|
+
}
|
|
39
|
+
if (caught) {
|
|
40
|
+
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
41
|
+
expect(caught.status).to.be.gte(400);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
// Note: a "user does not exist" path can't be exercised against pryv.me
|
|
45
|
+
// because per-user DNS doesn't resolve for unregistered usernames — fetch
|
|
46
|
+
// throws TypeError before any HTTP exchange. That's an environmental
|
|
47
|
+
// limit, not a code path; the bogus-reset-token test below covers the
|
|
48
|
+
// server-side error-mapping equivalent.
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('[PWSX] Service.resetPassword', function () {
|
|
52
|
+
let service;
|
|
53
|
+
|
|
54
|
+
before(async function () {
|
|
55
|
+
this.timeout(15000);
|
|
56
|
+
await testData.prepare();
|
|
57
|
+
service = new pryv.Service(testData.serviceInfoUrl);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('[PWSA] rejects when required args are missing', async function () {
|
|
61
|
+
let caught;
|
|
62
|
+
try {
|
|
63
|
+
await service.resetPassword(testData.username, 'newpw', '', 'jslib-test');
|
|
64
|
+
} catch (e) {
|
|
65
|
+
caught = e;
|
|
66
|
+
}
|
|
67
|
+
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('[PWSB] throws PryvError with structured fields on bogus reset token', async function () {
|
|
71
|
+
this.timeout(15000);
|
|
72
|
+
let caught;
|
|
73
|
+
try {
|
|
74
|
+
await service.resetPassword(
|
|
75
|
+
testData.username,
|
|
76
|
+
testData.password + 'X',
|
|
77
|
+
'bogus-reset-token-' + cuid().slice(0, 8),
|
|
78
|
+
'jslib-test'
|
|
79
|
+
);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
caught = e;
|
|
82
|
+
}
|
|
83
|
+
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
84
|
+
expect(caught.status).to.be.gte(400);
|
|
85
|
+
expect(caught.response).to.have.property('status');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, before, expect, pryv, testData */
|
|
6
|
+
|
|
7
|
+
const { createId: cuid } = require('@paralleldrive/cuid2');
|
|
8
|
+
|
|
9
|
+
describe('[USRX] Service.userExists', function () {
|
|
10
|
+
before(async function () {
|
|
11
|
+
this.timeout(15000);
|
|
12
|
+
await testData.prepare();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('[USRA] returns true for a registered user', async function () {
|
|
16
|
+
this.timeout(15000);
|
|
17
|
+
const service = new pryv.Service(testData.serviceInfoUrl);
|
|
18
|
+
const exists = await service.userExists(testData.username);
|
|
19
|
+
expect(exists).to.equal(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('[USRB] returns false for an unknown user (404)', async function () {
|
|
23
|
+
this.timeout(15000);
|
|
24
|
+
const service = new pryv.Service(testData.serviceInfoUrl);
|
|
25
|
+
const fakeUser = 'no-' + cuid().slice(0, 12);
|
|
26
|
+
const exists = await service.userExists(fakeUser);
|
|
27
|
+
expect(exists).to.equal(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, before, expect, pryv, testData */
|
|
6
|
+
|
|
7
|
+
const { createId: cuid } = require('@paralleldrive/cuid2');
|
|
8
|
+
|
|
9
|
+
describe('[UEMX] Service.userIdForEmail', function () {
|
|
10
|
+
before(async function () {
|
|
11
|
+
this.timeout(15000);
|
|
12
|
+
await testData.prepare();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('[UEMA] returns the username for a known email', async function () {
|
|
16
|
+
this.timeout(15000);
|
|
17
|
+
const service = new pryv.Service(testData.serviceInfoUrl);
|
|
18
|
+
const knownEmail = testData.username + '@pryv.io';
|
|
19
|
+
const userId = await service.userIdForEmail(knownEmail);
|
|
20
|
+
expect(userId).to.equal(testData.username);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('[UEMB] returns null for an unknown email', async function () {
|
|
24
|
+
this.timeout(15000);
|
|
25
|
+
const service = new pryv.Service(testData.serviceInfoUrl);
|
|
26
|
+
const unknownEmail = 'ghost-' + cuid().slice(0, 12) + '@example.com';
|
|
27
|
+
const userId = await service.userIdForEmail(unknownEmail);
|
|
28
|
+
expect(userId).to.equal(null);
|
|
29
|
+
});
|
|
30
|
+
});
|