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