pryv 3.0.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pryv",
3
- "version": "3.0.3",
3
+ "version": "3.0.4",
4
4
  "description": "Pryv JavaScript library",
5
5
  "keywords": [
6
6
  "Pryv",
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
- if (body?.error?.message) {
186
- throw new Error(body.error.message);
187
- }
188
- throw new Error('Login failed: ' + JSON.stringify(body));
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 Error('Invalid login response: ' + JSON.stringify(body));
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
- * Includes an innerObject property for wrapping underlying errors.
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 support
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;
@@ -5,26 +5,60 @@
5
5
 
6
6
  /**
7
7
  * Custom error class for Pryv library errors.
8
- * Includes an innerObject property for wrapping underlying errors.
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] - The underlying error or object that caused this error
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;
@@ -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
+ });