pryv 3.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,40 @@
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
+ * Plan 66: typed error surfaced when a Pryv.io server (≥ 2.0.0-pre.X)
10
+ * rejects an `accesses.update` or `accesses.delete` call with a
11
+ * 409 `stale-resource` response.
12
+ *
13
+ * The composite access id `<base>:<serial>` carries the version the
14
+ * caller last observed. If the access has since been updated, the
15
+ * server rejects the call so the caller refetches the current head
16
+ * (`connection.api('accesses.getOne', { id: base })`) and retries
17
+ * with the fresh composite id.
18
+ *
19
+ * Reach for `.data.provided` to see what the caller sent and
20
+ * `.data.currentSerial` to see what the server currently has.
21
+ *
22
+ * @extends PryvError
23
+ */
24
+ class StaleAccessIdError extends PryvError {
25
+ /**
26
+ * @param {string} message
27
+ * @param {{ provided?: string, currentSerial?: number | null }} data
28
+ */
29
+ constructor (message, data) {
30
+ super(message);
31
+ this.name = 'StaleAccessIdError';
32
+ /** @type {{ provided?: string, currentSerial?: number | null }} */
33
+ this.data = data || {};
34
+ if (Error.captureStackTrace) {
35
+ Error.captureStackTrace(this, StaleAccessIdError);
36
+ }
37
+ }
38
+ }
39
+
40
+ module.exports = StaleAccessIdError;
@@ -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/src/utils.js CHANGED
@@ -155,6 +155,56 @@ const utils = module.exports = {
155
155
  return url.replace(PRYV_REGEXP, '');
156
156
  },
157
157
 
158
+ /**
159
+ * Plan 66 (open-pryv.io ≥ 2.0.0-pre.X): parse a wire-format access
160
+ * reference into `{ base, serial }`. Accepts both bare cuid
161
+ * (`"abc123"` → `{ base: 'abc123', serial: null }`) and composite
162
+ * (`"abc123:3"` → `{ base: 'abc123', serial: 3 }`). Throws on
163
+ * malformed input. Apply this to `access.id`, `access.createdBy`,
164
+ * `access.modifiedBy`, and `streamIds` entries of the form
165
+ * `access-<base>:<serial>` from audit events.
166
+ * @memberof pryv.utils
167
+ * @param {string} ref - Access reference string
168
+ * @returns {{ base: string, serial: number | null }}
169
+ */
170
+ parseAccessRef: function (ref) {
171
+ if (typeof ref !== 'string' || ref.length === 0) {
172
+ throw new Error('parseAccessRef: expected a non-empty string, got ' + JSON.stringify(ref));
173
+ }
174
+ const colonIdx = ref.indexOf(':');
175
+ if (colonIdx === -1) return { base: ref, serial: null };
176
+ const base = ref.slice(0, colonIdx);
177
+ const tail = ref.slice(colonIdx + 1);
178
+ if (base.length === 0) {
179
+ throw new Error('parseAccessRef: empty base in ' + JSON.stringify(ref));
180
+ }
181
+ const serial = Number(tail);
182
+ if (!Number.isInteger(serial) || serial < 1) {
183
+ throw new Error('parseAccessRef: serial must be a positive integer, got ' + JSON.stringify(tail));
184
+ }
185
+ return { base, serial };
186
+ },
187
+
188
+ /**
189
+ * Plan 66: render an `{ base, serial }` pair back to the wire
190
+ * format. Bare cuid when serial is null/undefined; `<base>:<serial>`
191
+ * otherwise. Mostly used to construct the composite id when calling
192
+ * `connection.api()` for `accesses.update` / `accesses.delete`.
193
+ * @memberof pryv.utils
194
+ * @param {{ base: string, serial?: number | null }} ref
195
+ * @returns {string}
196
+ */
197
+ serializeAccessRef: function (ref) {
198
+ if (ref == null || typeof ref.base !== 'string' || ref.base.length === 0) {
199
+ throw new Error('serializeAccessRef: ref.base must be a non-empty string');
200
+ }
201
+ if (ref.serial == null) return ref.base;
202
+ if (!Number.isInteger(ref.serial) || ref.serial < 1) {
203
+ throw new Error('serializeAccessRef: serial must be a positive integer, got ' + JSON.stringify(ref.serial));
204
+ }
205
+ return ref.base + ':' + ref.serial;
206
+ },
207
+
158
208
  /**
159
209
  * Extract query parameters from a URL
160
210
  * @memberof pryv.utils
@@ -499,5 +499,31 @@ describe('[CONX] Connection', () => {
499
499
  expect(accessInfoUser.token).to.exist;
500
500
  expect(newUser.access.token).to.equal(accessInfoUser.token);
501
501
  });
502
+
503
+ // Plan 66 — accessInfo caching + forceRefresh.
504
+
505
+ it('[CAIC] accessInfo() memoizes — second call returns the same object reference', async () => {
506
+ const regexAPIandToken = /(.+):\/\/(.+)/gm;
507
+ const res = regexAPIandToken.exec(testData.apiEndpoint);
508
+ const apiEndpointWithToken = res[1] + '://' + newUser.access.token + '@' + res[2];
509
+ const cachingConn = new pryv.Connection(apiEndpointWithToken);
510
+ const first = await cachingConn.accessInfo();
511
+ const second = await cachingConn.accessInfo();
512
+ expect(second).to.equal(first); // same reference — served from cache
513
+ });
514
+
515
+ it('[CAID] accessInfo(true) forces a refresh and replaces the cached object', async () => {
516
+ const regexAPIandToken = /(.+):\/\/(.+)/gm;
517
+ const res = regexAPIandToken.exec(testData.apiEndpoint);
518
+ const apiEndpointWithToken = res[1] + '://' + newUser.access.token + '@' + res[2];
519
+ const cachingConn = new pryv.Connection(apiEndpointWithToken);
520
+ const first = await cachingConn.accessInfo();
521
+ const refreshed = await cachingConn.accessInfo(true);
522
+ expect(refreshed).to.not.equal(first); // distinct object — re-fetched
523
+ expect(refreshed.token).to.equal(first.token); // same content
524
+ // Next non-forced call returns the refreshed copy from cache.
525
+ const cached = await cachingConn.accessInfo();
526
+ expect(cached).to.equal(refreshed);
527
+ });
502
528
  });
503
529
  });
@@ -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
+ });