pryv 3.13.0 → 3.14.2

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,71 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+
6
+ /**
7
+ * The poll URL the server issued for each auth request started in this
8
+ * process, by key.
9
+ *
10
+ * A pending auth request lives on the core that created it, and its poll URL
11
+ * points at that core. The service's `access` URL may reach any core of a
12
+ * multi-core platform, so polling `access + key` can land on a core that does
13
+ * not know the request. Polling by key therefore uses the server-issued URL
14
+ * when this process started the request, and falls back to `access + key`
15
+ * only for a key it never saw.
16
+ *
17
+ * Keys are long random server values, so one process-wide map (like the
18
+ * hand-off cache) serves every Service.
19
+ */
20
+
21
+ /** Most entries kept (oldest dropped first), sized for a busy Node service. */
22
+ const MAX_ENTRIES = 1000;
23
+ /** How long an entry is kept: well beyond any auth request's lifetime. */
24
+ const TTL_MS = 60 * 60 * 1000;
25
+
26
+ /** key -> { pollUrl, expiresAt }, in insertion order */
27
+ const pollUrls = new Map();
28
+
29
+ /** Drop expired entries (called on every remember). */
30
+ function sweep () {
31
+ const now = Date.now();
32
+ for (const [key, entry] of pollUrls) {
33
+ if (now > entry.expiresAt) pollUrls.delete(key);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Remember the poll URL of an auth request (ignored unless both look valid).
39
+ * @param {string} key
40
+ * @param {string} pollUrl
41
+ */
42
+ function remember (key, pollUrl) {
43
+ if (typeof key !== 'string' || key === '' || typeof pollUrl !== 'string' || !/^https?:\/\//.test(pollUrl)) return;
44
+ sweep();
45
+ pollUrls.delete(key);
46
+ pollUrls.set(key, { pollUrl, expiresAt: Date.now() + TTL_MS });
47
+ while (pollUrls.size > MAX_ENTRIES) pollUrls.delete(pollUrls.keys().next().value);
48
+ }
49
+
50
+ /**
51
+ * The server-issued poll URL of `key`, or null when this process did not
52
+ * start that request (or it expired).
53
+ * @param {string} key
54
+ * @returns {string|null}
55
+ */
56
+ function lookup (key) {
57
+ const entry = pollUrls.get(key);
58
+ if (entry == null) return null;
59
+ if (Date.now() > entry.expiresAt) {
60
+ pollUrls.delete(key);
61
+ return null;
62
+ }
63
+ return entry.pollUrl;
64
+ }
65
+
66
+ /** Forget every entry. */
67
+ function clear () {
68
+ pollUrls.clear();
69
+ }
70
+
71
+ module.exports = { remember, lookup, clear, MAX_ENTRIES, TTL_MS };
package/src/utils.js CHANGED
@@ -303,7 +303,13 @@ const utils = module.exports = {
303
303
  url.replace(QUERY_REGEXP,
304
304
  // @ts-ignore - replace callback is used for side effects
305
305
  function (m, key, value) {
306
- vars[key] = decodeURIComponent(value);
306
+ // a malformed escape (`%` not followed by hex) is kept as is rather
307
+ // than throwing: any page URL reaches this parser
308
+ try {
309
+ vars[key] = decodeURIComponent(value);
310
+ } catch (e) {
311
+ vars[key] = value;
312
+ }
307
313
  });
308
314
  return vars;
309
315
  }
@@ -7,6 +7,7 @@
7
7
  const AuthController = require('../src/Auth/AuthController');
8
8
  const AuthStates = require('../src/Auth/AuthStates');
9
9
  const Service = require('../src/Service');
10
+ const PryvError = require('../src/lib/PryvError');
10
11
 
11
12
  describe('[ACNX] AuthController', function () {
12
13
  this.timeout(15000);
@@ -243,6 +244,55 @@ describe('[ACNX] AuthController', function () {
243
244
  expect(reloaded.accountUrl()).to.equal(profile('https://ui.example.com'));
244
245
  });
245
246
 
247
+ it('[ACAT] autologin skips a stored sign-in without a token (legacy and multi-account shapes)', async function () {
248
+ const cases = [
249
+ [{ username: 'u', apiEndpoint: 'https://u.example.com/' }, AuthStates.INITIALIZED],
250
+ [{ username: 'u', apiEndpoint: 'https://t@u.example.com/' }, AuthStates.AUTHORIZED],
251
+ [{ username: 'u', apiEndpoint: 'https://u.example.com/', profiles: [{ username: 'u', apiEndpoint: 'https://u.example.com/' }] }, AuthStates.INITIALIZED],
252
+ [{ username: 'u', apiEndpoint: 'https://t@u.example.com/', profiles: [{ username: 'u', apiEndpoint: 'https://t@u.example.com/' }] }, AuthStates.AUTHORIZED]
253
+ ];
254
+ for (const [stored, expected] of cases) {
255
+ const button = {
256
+ getAuthorizationData: () => stored,
257
+ onStateChange: async () => {},
258
+ onClick: () => {}
259
+ };
260
+ const auth = new AuthController({ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] } }, service, button);
261
+ await auth.init();
262
+ expect(auth.state.status, JSON.stringify(stored)).to.equal(expected);
263
+ }
264
+ });
265
+
266
+ it('[ACAU] autologin forgets the stored sign-ins without a token and keeps the usable ones', async function () {
267
+ let saved = {
268
+ username: 'u',
269
+ apiEndpoint: 'https://t@u.example.com/',
270
+ profiles: [
271
+ { username: 'u', apiEndpoint: 'https://t@u.example.com/' },
272
+ { username: 'stale', apiEndpoint: 'https://stale.example.com/' }
273
+ ]
274
+ };
275
+ let deleted = 0;
276
+ const button = {
277
+ getAuthorizationData: () => saved,
278
+ saveAuthorizationData: (d) => { saved = d; },
279
+ deleteAuthorizationData: () => { deleted++; saved = null; },
280
+ onStateChange: async () => {},
281
+ onClick: () => {}
282
+ };
283
+ const auth = new AuthController({ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] } }, service, button);
284
+ await auth.init();
285
+ expect(auth.state.status).to.equal(AuthStates.AUTHORIZED);
286
+ expect(saved.profiles.map((p) => p.username)).to.deep.equal(['u']);
287
+
288
+ // only token-less sign-ins stored: all forgotten, not signed in
289
+ saved = { username: 'stale', apiEndpoint: 'https://stale.example.com/' };
290
+ const auth2 = new AuthController({ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] } }, service, button);
291
+ await auth2.init();
292
+ expect(auth2.state.status).to.equal(AuthStates.INITIALIZED);
293
+ expect(deleted).to.equal(1);
294
+ });
295
+
246
296
  it('[ACUE] a button with showMenu gets the click instead of SIGNOUT, unless it declines', async function () {
247
297
  for (const [answer, expectSignout] of [[true, false], [undefined, false], [false, true]]) {
248
298
  const states = [];
@@ -266,8 +316,8 @@ describe('[ACNX] AuthController', function () {
266
316
  });
267
317
  });
268
318
 
269
- describe('[ACSX] stopAuthRequest', function () {
270
- it('[ACSA] sets error state with message', async function () {
319
+ describe('[ASRX] stopAuthRequest', function () {
320
+ it('[ASRA] sets error state with message', async function () {
271
321
  const auth = new AuthController({
272
322
  authRequest: {
273
323
  requestingAppId: 'test-app',
@@ -283,8 +333,8 @@ describe('[ACNX] AuthController', function () {
283
333
  });
284
334
  });
285
335
 
286
- describe('[ACRX] returnURL', function () {
287
- it('[ACRA] throws on invalid returnURL trailer', function () {
336
+ describe('[ARUX] returnURL', function () {
337
+ it('[ARUA] throws on invalid returnURL trailer', function () {
288
338
  const auth = new AuthController({
289
339
  authRequest: {
290
340
  requestingAppId: 'test-app',
@@ -295,7 +345,7 @@ describe('[ACNX] AuthController', function () {
295
345
  expect(() => auth.getReturnURL('http://example.com')).to.throw('Last character');
296
346
  });
297
347
 
298
- it('[ACRB] handles null/undefined returnURL', function () {
348
+ it('[ARUB] handles null/undefined returnURL', function () {
299
349
  const auth = new AuthController({
300
350
  authRequest: {
301
351
  requestingAppId: 'test-app',
@@ -307,6 +357,26 @@ describe('[ACNX] AuthController', function () {
307
357
  const result = auth.getReturnURL(undefined, 'http://test.com', { userAgent: 'Safari' });
308
358
  expect(result).to.equal(false);
309
359
  });
360
+
361
+ it('[ACNV] auto mode reads the browser navigator when no mock is given', function () {
362
+ const auth = new AuthController({
363
+ authRequest: {
364
+ requestingAppId: 'test-app',
365
+ requestedPermissions: []
366
+ }
367
+ }, service);
368
+ const saved = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
369
+ const iPhone = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148';
370
+ try {
371
+ Object.defineProperty(globalThis, 'navigator', { value: { userAgent: iPhone }, configurable: true, writable: true });
372
+ expect(auth.getReturnURL('auto#', 'http://test.com/app')).to.equal('http://test.com/app#');
373
+ Object.defineProperty(globalThis, 'navigator', { value: { userAgent: 'Safari' }, configurable: true, writable: true });
374
+ expect(auth.getReturnURL('auto#', 'http://test.com/app')).to.equal(false);
375
+ } finally {
376
+ if (saved) Object.defineProperty(globalThis, 'navigator', saved);
377
+ else delete globalThis.navigator;
378
+ }
379
+ });
310
380
  });
311
381
 
312
382
  describe('[AIST] State', function () {
@@ -474,4 +544,40 @@ describe('[ACNX] AuthController', function () {
474
544
  expect(auth.state.token).to.equal(undefined);
475
545
  });
476
546
  });
547
+
548
+ describe('[ACQX] a refused access request', function () {
549
+ let realFetch;
550
+
551
+ afterEach(function () {
552
+ if (realFetch) global.fetch = realFetch;
553
+ realFetch = null;
554
+ });
555
+
556
+ it('[ACQ1] the error carries the server message, id and status; the answer stays out of the message', async function () {
557
+ const SECRET = 'S3CRET-stream-id';
558
+ const body = { error: { id: 'invalid-parameters-format', message: 'The parameters are not valid.', data: { requestedPermissions: [{ streamId: SECRET }] } } };
559
+ realFetch = global.fetch;
560
+ global.fetch = async function () {
561
+ return { ok: false, status: 400, json: async () => body };
562
+ };
563
+ const auth = new AuthController({
564
+ authRequest: { requestingAppId: 'test-app', requestedPermissions: [{ streamId: SECRET, level: 'read' }] }
565
+ }, service);
566
+ auth.serviceInfo = { access: 'https://reg.test.local/access', register: 'https://reg.test.local/' };
567
+ let error = null;
568
+ try {
569
+ await auth.startAuthRequest();
570
+ } catch (e) {
571
+ error = e;
572
+ }
573
+ expect(error).to.be.instanceOf(PryvError);
574
+ expect(error.message).to.equal('The parameters are not valid.');
575
+ expect(error.message).to.not.include(SECRET);
576
+ expect(error.id).to.equal('invalid-parameters-format');
577
+ expect(error.status).to.equal(400);
578
+ expect(error.response.body).to.deep.equal(body);
579
+ expect(auth.state.status).to.equal(AuthStates.ERROR);
580
+ expect(auth.state.error).to.equal(error);
581
+ });
582
+ });
477
583
  });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+ /* global describe, it, expect, pryv */
6
+
7
+ /**
8
+ * [CAEX] `apiOne()` errors never carry the call's params in their message.
9
+ *
10
+ * Params can hold passwords and tokens, and error messages end up in logs and
11
+ * on screen. The message names the method and the server's error; the full
12
+ * error (or result) stays on `innerObject`. `api()` is stubbed: no server.
13
+ */
14
+
15
+ const SECRET = 'pa55-S3CRET-value';
16
+
17
+ function connWithResult (result) {
18
+ const conn = new pryv.Connection('https://sometoken@alice.example.test/');
19
+ conn.api = async () => result;
20
+ return conn;
21
+ }
22
+
23
+ async function caught (promise) {
24
+ try {
25
+ await promise;
26
+ } catch (e) {
27
+ return e;
28
+ }
29
+ throw new Error('expected a rejection');
30
+ }
31
+
32
+ describe('[CAEX] apiOne() error message', function () {
33
+ it('[CAEA] an error answer: method and server error in the message, params left out', async () => {
34
+ const serverError = { id: 'invalid-credentials', message: 'The given username/password pair is invalid.' };
35
+ const conn = connWithResult([{ error: serverError }]);
36
+ const e = await caught(conn.apiOne('auth.login', { username: 'alice', password: SECRET }));
37
+ expect(e).to.be.instanceOf(pryv.PryvError);
38
+ expect(e.message).to.include('"auth.login"');
39
+ expect(e.message).to.include('invalid-credentials');
40
+ expect(e.message).to.include('The given username/password pair is invalid.');
41
+ expect(e.message).to.not.include(SECRET);
42
+ expect(e.message).to.not.include('alice');
43
+ expect(e.innerObject).to.equal(serverError);
44
+ });
45
+
46
+ it('[CAEB] expected key missing: named in the message, result data left out', async () => {
47
+ const result = [{ access: { token: SECRET } }];
48
+ const conn = connWithResult(result);
49
+ const e = await caught(conn.apiOne('accesses.create', { name: 'x', token: SECRET }, 'accessX'));
50
+ expect(e).to.be.instanceOf(pryv.PryvError);
51
+ expect(e.message).to.include('"accesses.create"');
52
+ expect(e.message).to.include('"accessX" missing in result');
53
+ expect(e.message).to.not.include(SECRET);
54
+ expect(e.innerObject).to.equal(result);
55
+ });
56
+
57
+ it('[CAEC] no result at all', async () => {
58
+ const conn = connWithResult([]);
59
+ const e = await caught(conn.apiOne('events.get', { token: SECRET }));
60
+ expect(e).to.be.instanceOf(pryv.PryvError);
61
+ expect(e.message).to.include('"events.get"');
62
+ expect(e.message).to.include('no result');
63
+ expect(e.message).to.not.include(SECRET);
64
+ });
65
+
66
+ it('[CAED] an error answer without id or message', async () => {
67
+ const serverError = {};
68
+ const conn = connWithResult([{ error: serverError }]);
69
+ const e = await caught(conn.apiOne('events.get', { token: SECRET }));
70
+ expect(e.message).to.include('"events.get"');
71
+ expect(e.message).to.include('error answer');
72
+ expect(e.message).to.not.include('undefined');
73
+ expect(e.innerObject).to.equal(serverError);
74
+ });
75
+ });
76
+
77
+ /**
78
+ * [CAEY] A batch answer that breaks the protocol: the answer rides on
79
+ * `innerObject`, never in the message (results can hold tokens).
80
+ */
81
+ describe('[CAEY] api() protocol-violation errors', function () {
82
+ function connWithAnswer (answer) {
83
+ const conn = new pryv.Connection('https://sometoken@alice.example.test/');
84
+ conn.post = async () => answer;
85
+ return conn;
86
+ }
87
+
88
+ it('[CAEE] an answer without results', async () => {
89
+ const answer = { error: { id: 'x', message: 'y', data: { token: SECRET } } };
90
+ const e = await caught(connWithAnswer(answer).api([{ method: 'events.get', params: {} }]));
91
+ expect(e).to.be.instanceOf(pryv.PryvError);
92
+ expect(e.message).to.include('not an Array');
93
+ expect(e.message).to.not.include(SECRET);
94
+ expect(e.innerObject).to.equal(answer);
95
+ });
96
+
97
+ it('[CAEF] results that do not match the calls', async () => {
98
+ const answer = { results: [{ access: { token: SECRET } }, {}] };
99
+ const e = await caught(connWithAnswer(answer).api([{ method: 'accesses.create', params: {} }]));
100
+ expect(e).to.be.instanceOf(pryv.PryvError);
101
+ expect(e.message).to.include('does not match');
102
+ expect(e.message).to.not.include(SECRET);
103
+ expect(e.innerObject).to.equal(answer);
104
+ });
105
+ });
@@ -273,7 +273,9 @@ describe('[LBTX] LoginButton', function () {
273
273
  loginBtn.saveAuthorizationData({ apiEndpoint: 'https://tok@menu-user.example.com/', username: 'menu-user' });
274
274
  loginBtn.showMenu();
275
275
  dialog().querySelector('.pryv-menu-logout').click();
276
- await new Promise((resolve) => setTimeout(resolve, 50));
276
+ // the re-init after a logout is exposed as `pending`: wait for it, not
277
+ // for a fixed delay (a slow re-init was still LOADING after 50 ms)
278
+ await loginBtn.pending;
277
279
  expect(states.filter((s) => s === AuthStates.SIGNOUT)).to.have.lengthOf(1);
278
280
  expect(confirms).to.equal(0);
279
281
  expect(dialog()).to.equal(null);