pryv 3.12.1 → 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.
@@ -2,7 +2,7 @@
2
2
  * @license
3
3
  * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
4
  */
5
- /* global describe, it, before, after, expect, JSDOM, testData */
5
+ /* global describe, it, before, after, afterEach, expect, JSDOM, testData */
6
6
 
7
7
  const LoginButton = require('../src/Browser/LoginButton');
8
8
  const Service = require('../src/Service');
@@ -168,25 +168,35 @@ describe('[LBTX] LoginButton', function () {
168
168
  expect(loginBtn.text).to.include('Test error');
169
169
  });
170
170
 
171
- it('[LBSE] handles SIGNOUT state when confirmed', async function () {
172
- // Save some auth data first
171
+ it('[LBSE] handles SIGNOUT state by asking in a built-in dialog', async function () {
173
172
  loginBtn.saveAuthorizationData({ test: 'data' });
174
173
  await loginBtn.onStateChange({ status: AuthStates.SIGNOUT });
175
- // Confirm is mocked to return true
174
+ expect(document.querySelector('.pryv-menu .pryv-menu-logout')).to.exist;
175
+ expect(document.querySelector('.pryv-menu').getAttribute('role')).to.equal('alertdialog');
176
+ loginBtn.closeMenu(true); // no re-initialization running into the next test
176
177
  });
177
178
 
178
- it('[LBSG] SIGNOUT waits for the re-init to finish (none left running in the background)', async function () {
179
- // A re-init that outlives onStateChange resumes later and consumes
180
- // whatever poll URL the page shows by then (it raced [LBRU] on CI).
179
+ it('[LBSG] the re-init after the logout question is awaitable, never left running untracked', async function () {
180
+ // A re-init that nobody waits for resumes later and consumes whatever
181
+ // poll URL the page shows by then (it raced [LBRU] on CI). SIGNOUT itself
182
+ // starts none now: it asks first; "Log out" and a dismissal each start
183
+ // one and expose it (`pending`, and closeMenu's promise).
181
184
  const originalInit = loginBtn.auth.init;
182
- let reinitDone = false;
185
+ let reinits = 0;
183
186
  loginBtn.auth.init = async function () {
184
187
  await new Promise((resolve) => setTimeout(resolve, 50));
185
- reinitDone = true;
188
+ reinits++;
186
189
  };
187
190
  try {
188
191
  await loginBtn.onStateChange({ status: AuthStates.SIGNOUT });
189
- expect(reinitDone).to.equal(true);
192
+ expect(reinits).to.equal(0);
193
+ document.querySelector('.pryv-menu .pryv-menu-logout').click();
194
+ await loginBtn.pending;
195
+ expect(reinits).to.equal(1);
196
+
197
+ await loginBtn.onStateChange({ status: AuthStates.SIGNOUT });
198
+ await loginBtn.closeMenu(); // dismissed
199
+ expect(reinits).to.equal(2);
190
200
  } finally {
191
201
  loginBtn.auth.init = originalInit;
192
202
  }
@@ -198,6 +208,559 @@ describe('[LBTX] LoginButton', function () {
198
208
  });
199
209
  });
200
210
 
211
+ describe('[LBMX] account menu', function () {
212
+ let confirms;
213
+ let originalConfirm;
214
+
215
+ before(() => {
216
+ originalConfirm = global.confirm;
217
+ global.confirm = () => { confirms++; return true; };
218
+ });
219
+ after(() => { global.confirm = originalConfirm; });
220
+
221
+ async function signedInButton (extra) {
222
+ confirms = 0;
223
+ const states = [];
224
+ const settings = Object.assign({
225
+ spanButtonID: 'loginButton',
226
+ authRequest: { requestingAppId: 'test-app-menu', requestedPermissions: [] },
227
+ onStateChange: (s) => states.push(s.status),
228
+ accountUrl: 'https://account.example.com'
229
+ }, extra);
230
+ const loginBtn = new LoginButton(settings, service);
231
+ await loginBtn.init();
232
+ loginBtn.auth.state = { status: AuthStates.AUTHORIZED, username: 'menu-user', apiEndpoint: 'https://tok@menu-user.example.com/' };
233
+ states.length = 0;
234
+ return { loginBtn, states };
235
+ }
236
+ const dialog = () => document.querySelector('.pryv-menu');
237
+ const key = (k, opts) => document.dispatchEvent(new window.KeyboardEvent('keydown', Object.assign({ key: k, bubbles: true }, opts)));
238
+
239
+ afterEach(() => {
240
+ document.querySelectorAll('.pryv-menu-overlay').forEach((n) => n.parentNode.removeChild(n));
241
+ });
242
+
243
+ it('[LBMA] a click on the signed-in button opens the menu and emits no state', async function () {
244
+ const { loginBtn, states } = await signedInButton();
245
+ loginBtn.onClick();
246
+ await new Promise((resolve) => setTimeout(resolve, 0));
247
+ expect(states).to.deep.equal([]);
248
+ expect(confirms).to.equal(0);
249
+ expect(dialog()).to.exist;
250
+ expect(dialog().getAttribute('role')).to.equal('dialog');
251
+ expect(dialog().getAttribute('aria-modal')).to.equal('true');
252
+ expect(dialog().querySelector('.pryv-menu-username').textContent).to.equal('menu-user');
253
+ expect(dialog().querySelector('.pryv-menu-info').textContent).to.include('test-app-menu');
254
+ expect(dialog().querySelector('.pryv-menu-logout')).to.exist;
255
+ expect(dialog().querySelector('.pryv-menu-account')).to.exist;
256
+ // the built-in style comes first, so a service stylesheet overrides it
257
+ expect(document.head.firstChild.id).to.equal('pryv-menu-style');
258
+ expect(dialog().getAttribute('aria-labelledby')).to.equal('pryv-menu-username');
259
+ });
260
+
261
+ it('[LBMH] the stored credentials keep the auth page URL of the sign-in', async function () {
262
+ const { loginBtn } = await signedInButton();
263
+ await loginBtn.onStateChange({ status: AuthStates.AUTHORIZED, username: 'menu-user', apiEndpoint: 'https://tok@menu-user.example.com/', authUrl: 'https://ui.example.com/auth' });
264
+ expect(loginBtn.getAuthorizationData().authUrl).to.equal('https://ui.example.com/auth');
265
+ // the ACCEPTED poll body carries no authUrl: the one remembered from the request is used
266
+ loginBtn.auth._authUrl = 'https://ui2.example.com/auth';
267
+ await loginBtn.onStateChange({ status: AuthStates.AUTHORIZED, username: 'menu-user', apiEndpoint: 'https://tok@menu-user.example.com/' });
268
+ expect(loginBtn.getAuthorizationData().authUrl).to.equal('https://ui2.example.com/auth');
269
+ });
270
+
271
+ it('[LBMB] "Log out" emits SIGNOUT exactly once, without a confirmation, and clears the credentials', async function () {
272
+ const { loginBtn, states } = await signedInButton();
273
+ loginBtn.saveAuthorizationData({ apiEndpoint: 'https://tok@menu-user.example.com/', username: 'menu-user' });
274
+ loginBtn.showMenu();
275
+ dialog().querySelector('.pryv-menu-logout').click();
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;
279
+ expect(states.filter((s) => s === AuthStates.SIGNOUT)).to.have.lengthOf(1);
280
+ expect(confirms).to.equal(0);
281
+ expect(dialog()).to.equal(null);
282
+ expect(loginBtn.auth.state.status).to.equal(AuthStates.INITIALIZED);
283
+ const stored = loginBtn.getAuthorizationData();
284
+ expect(stored == null || stored.deleted === true).to.equal(true);
285
+ });
286
+
287
+ it('[LBMC] closes on its close button, on Escape and on a click outside, without logging out', async function () {
288
+ const { loginBtn, states } = await signedInButton();
289
+ loginBtn.showMenu();
290
+ dialog().querySelector('.pryv-menu-close').click();
291
+ expect(dialog()).to.equal(null);
292
+ loginBtn.showMenu();
293
+ key('Escape');
294
+ expect(dialog()).to.equal(null);
295
+ loginBtn.showMenu();
296
+ const overlay = document.querySelector('.pryv-menu-overlay');
297
+ overlay.dispatchEvent(new window.MouseEvent('mousedown', { bubbles: true }));
298
+ overlay.click();
299
+ expect(dialog()).to.equal(null);
300
+ // a text selection that started inside the dialog and ended outside it
301
+ loginBtn.showMenu();
302
+ dialog().dispatchEvent(new window.MouseEvent('mousedown', { bubbles: true }));
303
+ document.querySelector('.pryv-menu-overlay').click();
304
+ expect(dialog()).to.exist;
305
+ loginBtn.closeMenu();
306
+ loginBtn.showMenu();
307
+ dialog().click(); // inside: stays open
308
+ expect(dialog()).to.exist;
309
+ expect(states).to.deep.equal([]);
310
+ });
311
+
312
+ it('[LBMD] keeps focus inside the menu', async function () {
313
+ const { loginBtn } = await signedInButton();
314
+ loginBtn.showMenu();
315
+ const buttons = Array.from(dialog().querySelectorAll('button'));
316
+ expect(document.activeElement).to.equal(buttons[0]);
317
+ buttons[buttons.length - 1].focus();
318
+ key('Tab');
319
+ expect(document.activeElement).to.equal(buttons[0]);
320
+ key('Tab', { shiftKey: true });
321
+ expect(document.activeElement).to.equal(buttons[buttons.length - 1]);
322
+ });
323
+
324
+ it('[LBME] "Manage my account" opens the account app and closes the menu; hidden when unknown', async function () {
325
+ const { loginBtn } = await signedInButton();
326
+ const opened = [];
327
+ const original = window.open;
328
+ window.open = (...args) => { opened.push(args); return null; };
329
+ try {
330
+ loginBtn.showMenu();
331
+ dialog().querySelector('.pryv-menu-account').click();
332
+ } finally { window.open = original; }
333
+ expect(opened).to.have.lengthOf(1);
334
+ expect(opened[0][0]).to.match(/^https:\/\/account\.example\.com\/account\/profile\?pryvServiceInfoUrl=/);
335
+ expect(dialog()).to.equal(null);
336
+
337
+ const { loginBtn: unknown } = await signedInButton({ accountUrl: undefined });
338
+ unknown.auth.serviceInfo = Object.assign({}, unknown.auth.serviceInfo, { account: undefined });
339
+ unknown.showMenu();
340
+ expect(dialog().querySelector('.pryv-menu-account')).to.equal(null);
341
+ });
342
+
343
+ it('[LBMF] entries can be hidden', async function () {
344
+ for (const menu of [{ hide: ['account', 'info'] }, { account: false, info: false }]) {
345
+ const { loginBtn } = await signedInButton({ menu });
346
+ loginBtn.showMenu();
347
+ expect(dialog().querySelector('.pryv-menu-account')).to.equal(null);
348
+ expect(dialog().querySelector('.pryv-menu-info')).to.equal(null);
349
+ expect(dialog().querySelector('.pryv-menu-logout')).to.exist;
350
+ loginBtn.closeMenu();
351
+ }
352
+ });
353
+
354
+ it('[LBMG] menu: false keeps the legacy flow (SIGNOUT on click) with a built-in "Log out?" dialog, never confirm()', async function () {
355
+ const { loginBtn, states } = await signedInButton({ menu: false });
356
+ expect(loginBtn.showMenu()).to.equal(false);
357
+ loginBtn.saveAuthorizationData({ apiEndpoint: 'https://tok@menu-user.example.com/', username: 'menu-user' });
358
+ loginBtn.onClick();
359
+ await new Promise((resolve) => setTimeout(resolve, 50));
360
+ expect(states.filter((s) => s === AuthStates.SIGNOUT)).to.have.lengthOf(1);
361
+ expect(confirms).to.equal(0);
362
+ expect(dialog().querySelector('.pryv-menu-username').textContent).to.equal('Logout?');
363
+ expect(dialog().querySelector('.pryv-menu-account')).to.equal(null);
364
+ // Cancel keeps the credentials and returns to the signed-in state
365
+ // (the button used to stay inert after a cancelled logout)
366
+ states.length = 0;
367
+ dialog().querySelector('.pryv-menu-cancel').click();
368
+ expect(dialog()).to.equal(null);
369
+ await loginBtn.pending;
370
+ expect(loginBtn.getAuthorizationData().username).to.equal('menu-user');
371
+ expect(loginBtn.auth.state.status).to.equal(AuthStates.AUTHORIZED);
372
+ // the documented contract: re-initialized from the stored credentials
373
+ expect(states).to.deep.equal([AuthStates.LOADING, AuthStates.AUTHORIZED]);
374
+ // the service's button stylesheet is added once (a browser page carries others)
375
+ expect(Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter((l) => /buttonSignIn/.test(l.href)).length).to.be.at.most(1);
376
+ // Log out clears them and re-initializes
377
+ loginBtn.onClick();
378
+ await new Promise((resolve) => setTimeout(resolve, 50));
379
+ dialog().querySelector('.pryv-menu-logout').click();
380
+ await loginBtn.pending;
381
+ expect(confirms).to.equal(0);
382
+ expect(loginBtn.auth.state.status).to.equal(AuthStates.INITIALIZED);
383
+ const stored = loginBtn.getAuthorizationData();
384
+ expect(stored == null || stored.deleted === true).to.equal(true);
385
+ });
386
+ });
387
+
388
+ describe('[LBPX] several accounts and account switching', function () {
389
+ const utils = require('../src/utils');
390
+ const PARENT = 'parent-doe';
391
+ const KIM = 'kim-doe';
392
+ const hint = { isDelegatedAccess: true, controlledUsername: KIM, delegate: { username: PARENT } };
393
+ const dialog = () => document.querySelector('.pryv-menu');
394
+ let originals;
395
+
396
+ before(() => {
397
+ originals = { fetchPost: utils.fetchPost, fetchGet: utils.fetchGet, open: window.open };
398
+ window.open = () => ({ focus () {} });
399
+ });
400
+ afterEach(() => {
401
+ utils.fetchPost = originals.fetchPost;
402
+ utils.fetchGet = originals.fetchGet;
403
+ document.querySelectorAll('.pryv-menu-overlay').forEach((n) => n.parentNode.removeChild(n));
404
+ });
405
+ after(() => { window.open = originals.open; });
406
+
407
+ async function button (extra, stored) {
408
+ const states = [];
409
+ const settings = Object.assign({
410
+ spanButtonID: 'loginButton',
411
+ authRequest: { requestingAppId: 'test-app-profiles', requestedPermissions: [] },
412
+ onStateChange: (s) => states.push(s),
413
+ accountUrl: 'https://account.example.com'
414
+ }, extra);
415
+ const loginBtn = new LoginButton(settings, service);
416
+ loginBtn._cookieKey = 'pryv-libjs-test-app-profiles';
417
+ await loginBtn.deleteAuthorizationData();
418
+ if (stored != null) loginBtn.saveAuthorizationData(stored);
419
+ await loginBtn.init();
420
+ states.length = 0;
421
+ return { loginBtn, states };
422
+ }
423
+
424
+ /** Answer the next auth request with NEED_SIGNIN, then `final` on the first poll. */
425
+ function stubAuthRequest (final) {
426
+ const posted = [];
427
+ utils.fetchPost = async (url, body) => {
428
+ posted.push(body);
429
+ return { response: { ok: true }, body: { status: 'NEED_SIGNIN', key: 'k1', poll: 'https://reg.example.com/access/k1', poll_rate_ms: 10, authUrl: 'https://ui.example.com/auth?key=k1' } };
430
+ };
431
+ utils.fetchGet = async () => final;
432
+ return posted;
433
+ }
434
+
435
+ async function settle () {
436
+ await new Promise((resolve) => setTimeout(resolve, 50));
437
+ }
438
+
439
+ it('[LBPA] a grant for a controlled account is labelled "via" its delegate and remembered next to the own account', async function () {
440
+ const { loginBtn } = await button(null, { username: PARENT, apiEndpoint: 'https://tok1@parent.example.com/' });
441
+ expect(loginBtn.auth.currentProfile()).to.deep.equal({ username: PARENT });
442
+ stubAuthRequest({ response: { status: 200 }, body: { status: 'ACCEPTED', username: KIM, apiEndpoint: 'https://tok2@kim.example.com/', delegation: hint } });
443
+ await loginBtn.auth.addAccount();
444
+ await settle();
445
+ expect(loginBtn.text).to.equal(KIM + ' (via ' + PARENT + ')');
446
+ expect(loginBtn.auth.currentProfile()).to.deep.equal({ username: KIM, actingAs: { username: KIM, delegate: PARENT } });
447
+ expect(loginBtn.auth.profiles()).to.deep.equal([
448
+ { username: KIM, active: true, available: true, actingAs: { username: KIM, delegate: PARENT } },
449
+ { username: PARENT, active: false, available: true }
450
+ ]);
451
+ const stored = loginBtn.getAuthorizationData();
452
+ // the active account stays readable at the top level; the auth page is kept without its query
453
+ expect(stored.username).to.equal(KIM);
454
+ expect(stored.authUrl).to.equal('https://ui.example.com/auth');
455
+ // after a reload the same account is signed in, still labelled "via"
456
+ const { loginBtn: reloaded } = await button(null, stored);
457
+ expect(reloaded.auth.currentProfile().actingAs.delegate).to.equal(PARENT);
458
+ expect(reloaded.text).to.equal(KIM + ' (via ' + PARENT + ')');
459
+ });
460
+
461
+ it('[LBPB] "another account" sends actAs: allow; a switch to a named account sends that name; SWITCHING comes first', async function () {
462
+ const { loginBtn, states } = await button(null, { username: PARENT, apiEndpoint: 'https://tok1@parent.example.com/' });
463
+ let posted = stubAuthRequest({ response: { status: 200 }, body: { status: 'ACCEPTED', username: KIM, apiEndpoint: 'https://tok2@kim.example.com/', delegation: hint } });
464
+ await loginBtn.auth.addAccount();
465
+ await settle();
466
+ expect(posted[0].actAs).to.equal('allow');
467
+ expect(posted[0].requestingAppId).to.equal('test-app-profiles');
468
+ expect(states.map((s) => s.status)).to.deep.equal([AuthStates.SWITCHING, AuthStates.NEED_SIGNIN, AuthStates.AUTHORIZED]);
469
+ expect(states[0]).to.include({ from: PARENT, to: null });
470
+ // the app's own setting is not changed by a switch
471
+ expect(loginBtn.authSettings.authRequest.actAs).to.equal(undefined);
472
+ posted = stubAuthRequest({ response: { status: 200 }, body: { status: 'ACCEPTED', username: 'lou-doe', apiEndpoint: 'https://tok3@lou.example.com/' } });
473
+ await loginBtn.auth.switchTo('lou-doe');
474
+ await settle();
475
+ expect(posted[0].actAs).to.equal('lou-doe');
476
+ expect(loginBtn.auth.currentProfile().username).to.equal('lou-doe');
477
+ });
478
+
479
+ it('[LBPC] switching to a remembered account whose access is valid needs no sign-in', async function () {
480
+ const { loginBtn, states } = await button(null, {
481
+ username: KIM,
482
+ apiEndpoint: 'https://tok2@kim.example.com/',
483
+ actingAs: { username: KIM, delegate: PARENT },
484
+ profiles: [{ username: testData.username, apiEndpoint: testData.apiEndpointWithToken }]
485
+ });
486
+ utils.fetchPost = async () => { throw new Error('no auth request expected'); };
487
+ await loginBtn.auth.switchTo(testData.username);
488
+ expect(states.map((s) => s.status)).to.deep.equal([AuthStates.SWITCHING, AuthStates.AUTHORIZED]);
489
+ expect(states[0]).to.include({ from: KIM, to: testData.username });
490
+ expect(loginBtn.auth.currentProfile()).to.deep.equal({ username: testData.username });
491
+ expect(loginBtn.getAuthorizationData().username).to.equal(testData.username);
492
+ });
493
+
494
+ it('[LBPD] "switch back" re-activates the delegate\'s own account', async function () {
495
+ const { loginBtn } = await button(null, {
496
+ username: KIM,
497
+ apiEndpoint: 'https://tok2@kim.example.com/',
498
+ actingAs: { username: KIM, delegate: testData.username },
499
+ profiles: [{ username: 'someone-else', apiEndpoint: 'https://t@else.example.com/' }, { username: testData.username, apiEndpoint: testData.apiEndpointWithToken }]
500
+ });
501
+ await loginBtn.auth.switchTo(null);
502
+ expect(loginBtn.auth.currentProfile()).to.deep.equal({ username: testData.username });
503
+ // already on the own account: nothing to do
504
+ await loginBtn.auth.switchTo(null);
505
+ expect(loginBtn.auth.currentProfile().username).to.equal(testData.username);
506
+ });
507
+
508
+ it('[LBPE] a revoked account is marked "no longer available" and asked for again', async function () {
509
+ const revoked = testData.apiEndpointWithToken.replace(/\/\/[^@]+@/, '//revoked-token-x@');
510
+ const { loginBtn } = await button(null, {
511
+ username: PARENT,
512
+ apiEndpoint: 'https://tok1@parent.example.com/',
513
+ profiles: [{ username: testData.username, apiEndpoint: revoked, actingAs: { username: testData.username, delegate: PARENT } }]
514
+ });
515
+ const posted = stubAuthRequest({ response: { status: 403 }, body: { status: 'REFUSED' } });
516
+ await loginBtn.auth.switchTo(testData.username);
517
+ await settle();
518
+ expect(posted[0].actAs).to.equal(testData.username);
519
+ const listed = loginBtn.auth.profiles().find((p) => p.username === testData.username);
520
+ expect(listed.available).to.equal(false);
521
+ // the sign-in was refused: back on the previous account, not signed out
522
+ expect(loginBtn.auth.state.status).to.equal(AuthStates.AUTHORIZED);
523
+ expect(loginBtn.auth.currentProfile().username).to.equal(PARENT);
524
+ loginBtn.showMenu();
525
+ const item = Array.from(dialog().querySelectorAll('.pryv-menu-profile')).find((b) => b.textContent.startsWith(testData.username));
526
+ expect(item.textContent).to.include('no longer available');
527
+ });
528
+
529
+ it('[LBPF] the menu offers the remembered accounts, "another account" with delegation, and "Log out of all accounts"', async function () {
530
+ const { loginBtn } = await button(null, {
531
+ username: PARENT,
532
+ apiEndpoint: 'https://tok1@parent.example.com/',
533
+ profiles: [{ username: KIM, apiEndpoint: 'https://tok2@kim.example.com/', actingAs: { username: KIM, delegate: PARENT } }]
534
+ });
535
+ loginBtn.serviceInfo = Object.assign({}, loginBtn.serviceInfo, { features: { delegation: true } });
536
+ loginBtn.showMenu();
537
+ const items = Array.from(dialog().querySelectorAll('.pryv-menu-profile'));
538
+ expect(items.map((b) => b.textContent)).to.deep.equal([PARENT + ' (me)', KIM + ' via ' + PARENT]);
539
+ expect(items[0].getAttribute('aria-current')).to.equal('true');
540
+ expect(dialog().querySelector('.pryv-menu-switch-title').textContent).to.equal('Use this app for');
541
+ expect(dialog().querySelector('.pryv-menu-add-account')).to.exist;
542
+ expect(dialog().querySelector('.pryv-menu-logout-all')).to.exist;
543
+ loginBtn.closeMenu();
544
+ // hidden with the option; nothing to offer without delegation and a single account
545
+ const { loginBtn: hidden } = await button({ menu: { hide: ['switch'] } }, { username: PARENT, apiEndpoint: 'https://tok1@parent.example.com/', profiles: [{ username: KIM, apiEndpoint: 'https://tok2@kim.example.com/' }] });
546
+ hidden.showMenu();
547
+ expect(dialog().querySelector('.pryv-menu-switch')).to.equal(null);
548
+ hidden.closeMenu();
549
+ const { loginBtn: single } = await button(null, { username: PARENT, apiEndpoint: 'https://tok1@parent.example.com/' });
550
+ single.serviceInfo = Object.assign({}, single.serviceInfo, { features: { delegation: false } });
551
+ single.showMenu();
552
+ expect(dialog().querySelector('.pryv-menu-switch')).to.equal(null);
553
+ expect(dialog().querySelector('.pryv-menu-logout-all')).to.equal(null);
554
+ });
555
+
556
+ it('[LBPG] acting for an account: the menu says so and offers "Switch back"', async function () {
557
+ const { loginBtn } = await button(null, {
558
+ username: KIM,
559
+ apiEndpoint: 'https://tok2@kim.example.com/',
560
+ actingAs: { username: KIM, delegate: testData.username },
561
+ profiles: [{ username: testData.username, apiEndpoint: testData.apiEndpointWithToken }]
562
+ });
563
+ loginBtn.showMenu();
564
+ expect(dialog().querySelector('.pryv-menu-username').textContent).to.equal(KIM + ' (acting as, via ' + testData.username + ')');
565
+ expect(dialog().querySelector('.pryv-menu-account').textContent).to.include("Manage kim-doe's account");
566
+ expect(dialog().querySelector('.pryv-menu-profile')).to.equal(null);
567
+ dialog().querySelector('.pryv-menu-switch-back').click();
568
+ expect(dialog()).to.equal(null);
569
+ await loginBtn.pending;
570
+ expect(loginBtn.auth.currentProfile()).to.deep.equal({ username: testData.username });
571
+ });
572
+
573
+ it('[LBPH] "Log out" keeps the other accounts without signing in to one; "Log out of all accounts" forgets them', async function () {
574
+ const stored = {
575
+ username: KIM,
576
+ apiEndpoint: 'https://tok2@kim.example.com/',
577
+ profiles: [{ username: PARENT, apiEndpoint: 'https://tok1@parent.example.com/' }]
578
+ };
579
+ const { loginBtn, states } = await button(null, stored);
580
+ loginBtn.showMenu();
581
+ dialog().querySelector('.pryv-menu-logout').click();
582
+ await loginBtn.pending;
583
+ expect(states.filter((s) => s.status === AuthStates.SIGNOUT)).to.have.lengthOf(1);
584
+ expect(loginBtn.auth.state.status).to.equal(AuthStates.INITIALIZED);
585
+ const kept = loginBtn.getAuthorizationData();
586
+ expect(kept.username).to.equal(undefined);
587
+ expect(kept.profiles.map((p) => p.username)).to.deep.equal([PARENT]);
588
+
589
+ const { loginBtn: all } = await button(null, stored);
590
+ all.showMenu();
591
+ dialog().querySelector('.pryv-menu-logout-all').click();
592
+ await all.pending;
593
+ const cleared = all.getAuthorizationData();
594
+ expect(cleared == null || cleared.deleted === true).to.equal(true);
595
+ });
596
+
597
+ it('[LBPI] a failed re-initialization after a dismissed "Log out?" is handled, not left rejected', async function () {
598
+ const { loginBtn } = await button({ menu: false }, { username: PARENT, apiEndpoint: 'https://tok1@parent.example.com/' });
599
+ loginBtn.onClick();
600
+ await settle();
601
+ loginBtn.auth.init = async () => { throw new Error('offline'); };
602
+ await loginBtn.closeMenu();
603
+ });
604
+ it('[LBPJ] "switch back" never activates another remembered account: without the delegate\'s own, it asks for a sign-in', async function () {
605
+ const { loginBtn } = await button(null, {
606
+ username: KIM,
607
+ apiEndpoint: 'https://tok2@kim.example.com/',
608
+ actingAs: { username: KIM, delegate: PARENT },
609
+ profiles: [{ username: 'someone-else', apiEndpoint: testData.apiEndpointWithToken }]
610
+ });
611
+ let probed = 0;
612
+ loginBtn.auth._accessInfo = async () => { probed++; return { user: {} }; };
613
+ const posted = stubAuthRequest({ response: { status: 200 }, body: { status: 'ACCEPTED', username: PARENT, apiEndpoint: 'https://tok1@parent.example.com/' } });
614
+ await loginBtn.auth.switchTo(null);
615
+ await settle();
616
+ expect(probed).to.equal(0);
617
+ expect(posted).to.have.lengthOf(1);
618
+ expect(posted[0].actAs).to.equal('deny');
619
+ expect(loginBtn.auth.currentProfile().username).to.equal(PARENT);
620
+ });
621
+
622
+ it('[LBPK] a refused switch returns to the previous account without the key of its consumed sign-in', async function () {
623
+ const { loginBtn, states } = await button(null, { username: PARENT, apiEndpoint: 'https://tok1@parent.example.com/' });
624
+ loginBtn.auth.state = { status: AuthStates.AUTHORIZED, username: PARENT, apiEndpoint: 'https://tok1@parent.example.com/', key: 'k-consumed', profile: { username: PARENT, apiEndpoint: 'https://tok1@parent.example.com/' } };
625
+ states.length = 0;
626
+ stubAuthRequest({ response: { status: 403 }, body: { status: 'REFUSED' } });
627
+ await loginBtn.auth.addAccount();
628
+ await settle();
629
+ const last = states[states.length - 1];
630
+ expect(last.status).to.equal(AuthStates.AUTHORIZED);
631
+ expect(last.key).to.equal(undefined);
632
+ expect(last.username).to.equal(PARENT);
633
+ expect(loginBtn.auth.state.key).to.equal(undefined);
634
+ });
635
+
636
+ it('[LBPL] a switch abandoned by a log out cannot sign back in when its request is answered later', async function () {
637
+ const { loginBtn } = await button(null, { username: PARENT, apiEndpoint: 'https://tok1@parent.example.com/' });
638
+ let answer;
639
+ utils.fetchPost = async () => ({ response: { ok: true }, body: { status: 'NEED_SIGNIN', key: 'k2', poll: 'https://reg.example.com/access/k2', poll_rate_ms: 10, authUrl: 'https://ui.example.com/auth' } });
640
+ utils.fetchGet = (url, ...rest) => url === 'https://reg.example.com/access/k2'
641
+ ? new Promise((resolve) => { answer = resolve; })
642
+ : originals.fetchGet(url, ...rest);
643
+ const switching = loginBtn.auth.addAccount();
644
+ await settle();
645
+ await loginBtn.auth.signOut();
646
+ expect(loginBtn.auth.state.status).to.equal(AuthStates.INITIALIZED);
647
+ // a late grant of the abandoned request must not sign anyone in
648
+ answer({ response: { status: 200 }, body: { status: 'ACCEPTED', username: KIM, apiEndpoint: 'https://tok2@kim.example.com/' } });
649
+ await switching;
650
+ await settle();
651
+ expect(loginBtn.auth.state.status).to.equal(AuthStates.INITIALIZED);
652
+ const stored = loginBtn.getAuthorizationData();
653
+ expect(stored == null || stored.deleted === true || stored.username == null).to.equal(true);
654
+ });
655
+
656
+ it('[LBPN] a log out during the access check of a switch ends the switch', async function () {
657
+ const { loginBtn } = await button(null, {
658
+ username: PARENT,
659
+ apiEndpoint: 'https://tok1@parent.example.com/',
660
+ profiles: [{ username: KIM, apiEndpoint: 'https://tok2@kim.example.com/', actingAs: { username: KIM, delegate: PARENT } }]
661
+ });
662
+ let answer;
663
+ loginBtn.auth._accessInfo = () => new Promise((resolve) => { answer = resolve; });
664
+ const switching = loginBtn.auth.switchTo(KIM);
665
+ await settle();
666
+ await loginBtn.auth.signOut({ all: true });
667
+ answer({ user: { username: KIM } });
668
+ await switching;
669
+ expect(loginBtn.auth.state.status).to.equal(AuthStates.INITIALIZED);
670
+ const stored = loginBtn.getAuthorizationData();
671
+ expect(stored == null || stored.deleted === true || stored.username == null).to.equal(true);
672
+ });
673
+
674
+ it('[LBPO] the main cookie only ever holds the active account: an older version never reads the remembered list as signed in', async function () {
675
+ const Cookies = require('../src/Browser/CookieUtils');
676
+ const { loginBtn } = await button(null, {
677
+ username: KIM,
678
+ apiEndpoint: 'https://tok2@kim.example.com/',
679
+ authUrl: 'https://ui.example.com/auth',
680
+ profiles: [{ username: PARENT, apiEndpoint: 'https://tok1@parent.example.com/' }]
681
+ });
682
+ const key = loginBtn._cookieKey;
683
+ // what versions without account switching read
684
+ const main = Cookies.get(key);
685
+ expect(main.username).to.equal(KIM);
686
+ expect(main.profiles).to.equal(undefined);
687
+ expect(Cookies.get(key + '-profiles').profiles.map((p) => p.username)).to.deep.equal([KIM, PARENT]);
688
+ // log out of the active account: nothing left for them to sign in with
689
+ loginBtn.showMenu();
690
+ document.querySelector('.pryv-menu-logout').click();
691
+ await loginBtn.pending;
692
+ const after = Cookies.get(key);
693
+ expect(after == null || after.deleted === true).to.equal(true);
694
+ const remembered = loginBtn.getAuthorizationData();
695
+ expect(remembered.username).to.equal(undefined);
696
+ expect(remembered.profiles.map((p) => p.username)).to.deep.equal([PARENT]);
697
+ expect(remembered.authUrl).to.equal('https://ui.example.com/auth');
698
+ // log out of all: both cookies gone
699
+ await loginBtn.auth.signOut({ all: true });
700
+ const none = loginBtn.getAuthorizationData();
701
+ expect(none == null || none.deleted === true).to.equal(true);
702
+ const list = Cookies.get(key + '-profiles');
703
+ expect(list == null || list.deleted === true).to.equal(true);
704
+ });
705
+
706
+ it('[LBPQ] the remembered accounts are trimmed, least recent first, to fit in a cookie', async function () {
707
+ const Cookies = require('../src/Browser/CookieUtils');
708
+ const profiles = [];
709
+ for (let i = 0; i < 20; i++) {
710
+ profiles.push({ username: 'user-' + i, apiEndpoint: 'https://' + 'x'.repeat(120) + i + '@user-' + i + '.example.com/' });
711
+ }
712
+ const { loginBtn } = await button({ maxProfiles: 50 }, null);
713
+ loginBtn.saveAuthorizationData({ username: 'user-0', apiEndpoint: profiles[0].apiEndpoint, profiles });
714
+ const stored = Cookies.get(loginBtn._cookieKey + '-profiles');
715
+ expect(stored).to.exist;
716
+ expect(stored.profiles.length).to.be.below(20);
717
+ expect(stored.profiles.length).to.be.above(5);
718
+ expect(stored.profiles[0].username).to.equal('user-0');
719
+ expect(stored.profiles[stored.profiles.length - 1].username).to.equal('user-' + (stored.profiles.length - 1));
720
+ });
721
+
722
+ it('[LBPP] a stopped auth request no longer changes the state when its poll answers', async function () {
723
+ const { loginBtn } = await button(null, null);
724
+ let answer;
725
+ utils.fetchPost = async () => ({ response: { ok: true }, body: { status: 'NEED_SIGNIN', key: 'k3', poll: 'https://reg.example.com/access/k3', poll_rate_ms: 10, authUrl: 'https://ui.example.com/auth' } });
726
+ utils.fetchGet = (url, ...rest) => url === 'https://reg.example.com/access/k3'
727
+ ? new Promise((resolve) => { answer = resolve; })
728
+ : originals.fetchGet(url, ...rest);
729
+ const request = loginBtn.auth.startAuthRequest();
730
+ await settle();
731
+ loginBtn.auth.stopAuthRequest('stopped by the app');
732
+ answer({ response: { status: 200 }, body: { status: 'ACCEPTED', username: PARENT, apiEndpoint: 'https://tok1@parent.example.com/' } });
733
+ await request;
734
+ await settle();
735
+ expect(loginBtn.auth.state.status).to.equal(AuthStates.ERROR);
736
+ expect(loginBtn.auth.state.message).to.equal('stopped by the app');
737
+ });
738
+
739
+ it('[LBPM] only a refused access marks an account unavailable; a server error or a network failure keeps the previous account', async function () {
740
+ const stored = {
741
+ username: PARENT,
742
+ apiEndpoint: 'https://tok1@parent.example.com/',
743
+ profiles: [{ username: KIM, apiEndpoint: 'https://tok2@kim.example.com/', actingAs: { username: KIM, delegate: PARENT } }]
744
+ };
745
+ const { loginBtn } = await button(null, stored);
746
+ utils.fetchPost = async () => { throw new Error('no auth request expected'); };
747
+ for (const probe of [async () => ({ error: { id: 'unexpected-error' } }), async () => { throw new Error('offline'); }]) {
748
+ loginBtn.auth._accessInfo = probe;
749
+ let failed = false;
750
+ try { await loginBtn.auth.switchTo(KIM); } catch (e) { failed = true; }
751
+ expect(failed).to.equal(true);
752
+ expect(loginBtn.auth.currentProfile().username).to.equal(PARENT);
753
+ expect(loginBtn.auth.profiles().find((p) => p.username === KIM).available).to.equal(true);
754
+ }
755
+ // a refused token does mark it
756
+ loginBtn.auth._accessInfo = async () => ({ error: { id: 'invalid-access-token' } });
757
+ stubAuthRequest({ response: { status: 403 }, body: { status: 'REFUSED' } });
758
+ await loginBtn.auth.switchTo(KIM);
759
+ await settle();
760
+ expect(loginBtn.auth.profiles().find((p) => p.username === KIM).available).to.equal(false);
761
+ });
762
+ });
763
+
201
764
  describe('[LBWX] Without spanButtonID', function () {
202
765
  it('[LBWA] init works without span (logs warning)', async function () {
203
766
  const settings = {