@oxyhq/core 3.2.0 → 3.4.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +3 -1
- package/dist/cjs/HttpService.js +89 -0
- package/dist/cjs/OxyServices.js +1 -1
- package/dist/cjs/constants/version.js +1 -1
- package/dist/cjs/i18n/locales/en-US.json +44 -44
- package/dist/cjs/i18n/locales/es-ES.json +44 -44
- package/dist/cjs/i18n/locales/locales/en-US.json +44 -44
- package/dist/cjs/i18n/locales/locales/es-ES.json +44 -44
- package/dist/cjs/index.js +4 -0
- package/dist/cjs/mixins/OxyServices.applications.js +3 -1
- package/dist/cjs/mixins/OxyServices.reputation.js +244 -0
- package/dist/cjs/mixins/OxyServices.workspaces.js +3 -1
- package/dist/cjs/mixins/index.js +2 -2
- package/dist/cjs/utils/accountUtils.js +12 -5
- package/dist/cjs/utils/ssoReturn.js +80 -33
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +3 -1
- package/dist/esm/HttpService.js +89 -0
- package/dist/esm/OxyServices.js +1 -1
- package/dist/esm/constants/version.js +1 -1
- package/dist/esm/i18n/locales/en-US.json +44 -44
- package/dist/esm/i18n/locales/es-ES.json +44 -44
- package/dist/esm/i18n/locales/locales/en-US.json +44 -44
- package/dist/esm/i18n/locales/locales/es-ES.json +44 -44
- package/dist/esm/index.js +4 -0
- package/dist/esm/mixins/OxyServices.applications.js +3 -1
- package/dist/esm/mixins/OxyServices.reputation.js +241 -0
- package/dist/esm/mixins/OxyServices.workspaces.js +3 -1
- package/dist/esm/mixins/index.js +2 -2
- package/dist/esm/utils/accountUtils.js +12 -5
- package/dist/esm/utils/ssoReturn.js +80 -33
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +57 -0
- package/dist/types/OxyServices.d.ts +1 -1
- package/dist/types/constants/version.d.ts +2 -2
- package/dist/types/index.d.ts +2 -1
- package/dist/types/mixins/OxyServices.applications.d.ts +8 -2
- package/dist/types/mixins/OxyServices.features.d.ts +0 -1
- package/dist/types/mixins/OxyServices.reputation.d.ts +436 -0
- package/dist/types/mixins/OxyServices.workspaces.d.ts +8 -2
- package/dist/types/mixins/index.d.ts +2 -2
- package/dist/types/models/interfaces.d.ts +15 -26
- package/dist/types/utils/accountUtils.d.ts +17 -4
- package/dist/types/utils/ssoReturn.d.ts +30 -9
- package/package.json +2 -1
- package/src/AuthManager.ts +3 -1
- package/src/HttpService.ts +91 -0
- package/src/OxyServices.ts +1 -1
- package/src/__tests__/httpServiceCache.test.ts +198 -0
- package/src/constants/version.ts +1 -1
- package/src/i18n/locales/en-US.json +44 -44
- package/src/i18n/locales/es-ES.json +44 -44
- package/src/index.ts +32 -4
- package/src/mixins/OxyServices.applications.ts +8 -2
- package/src/mixins/OxyServices.auth.ts +2 -1
- package/src/mixins/OxyServices.features.ts +0 -1
- package/src/mixins/OxyServices.reputation.ts +674 -0
- package/src/mixins/OxyServices.workspaces.ts +8 -2
- package/src/mixins/__tests__/reputation.test.ts +408 -0
- package/src/mixins/index.ts +3 -3
- package/src/models/interfaces.ts +16 -32
- package/src/utils/__tests__/accountUtils.test.ts +142 -0
- package/src/utils/__tests__/consumeSsoReturn.test.ts +229 -37
- package/src/utils/accountUtils.ts +20 -5
- package/src/utils/ssoReturn.ts +98 -37
- package/dist/cjs/mixins/OxyServices.developer.js +0 -97
- package/dist/cjs/mixins/OxyServices.karma.js +0 -108
- package/dist/esm/mixins/OxyServices.developer.js +0 -94
- package/dist/esm/mixins/OxyServices.karma.js +0 -105
- package/dist/types/mixins/OxyServices.developer.d.ts +0 -106
- package/dist/types/mixins/OxyServices.karma.d.ts +0 -92
- package/src/mixins/OxyServices.karma.ts +0 -111
|
@@ -319,8 +319,8 @@ describe('consumeSsoReturn', () => {
|
|
|
319
319
|
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
320
320
|
});
|
|
321
321
|
|
|
322
|
-
describe('dest restore', () => {
|
|
323
|
-
it('restores a same-origin destination when on the callback path, removes the dest key, and
|
|
322
|
+
describe('ok dest restore (soft replaceState + popstate)', () => {
|
|
323
|
+
it('restores a same-origin destination when on the callback path, removes the dest key, dispatches popstate, and does NOT hard-redirect', async () => {
|
|
324
324
|
const oxy = okExchange();
|
|
325
325
|
const storage = makeStorage({
|
|
326
326
|
[ssoStateKey(ORIGIN)]: 's',
|
|
@@ -328,6 +328,7 @@ describe('consumeSsoReturn', () => {
|
|
|
328
328
|
});
|
|
329
329
|
const history = makeHistory();
|
|
330
330
|
const dispatchPopState = jest.fn();
|
|
331
|
+
const hardRedirect = jest.fn();
|
|
331
332
|
|
|
332
333
|
const result = await consumeSsoReturn(oxy, {
|
|
333
334
|
isWeb: () => true,
|
|
@@ -338,6 +339,7 @@ describe('consumeSsoReturn', () => {
|
|
|
338
339
|
}),
|
|
339
340
|
history,
|
|
340
341
|
dispatchPopState,
|
|
342
|
+
hardRedirect,
|
|
341
343
|
});
|
|
342
344
|
|
|
343
345
|
expect(result).toEqual(SESSION);
|
|
@@ -347,6 +349,8 @@ describe('consumeSsoReturn', () => {
|
|
|
347
349
|
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
348
350
|
// URL-driven routers must be told the location changed.
|
|
349
351
|
expect(dispatchPopState).toHaveBeenCalledTimes(1);
|
|
352
|
+
// ok preserves the in-memory session — NEVER a hard navigation.
|
|
353
|
+
expect(hardRedirect).not.toHaveBeenCalled();
|
|
350
354
|
});
|
|
351
355
|
|
|
352
356
|
it('restores a relative same-origin destination (new URL(dest, origin))', async () => {
|
|
@@ -356,6 +360,7 @@ describe('consumeSsoReturn', () => {
|
|
|
356
360
|
[ssoDestKey(ORIGIN)]: '/settings?tab=privacy',
|
|
357
361
|
});
|
|
358
362
|
const history = makeHistory();
|
|
363
|
+
const hardRedirect = jest.fn();
|
|
359
364
|
|
|
360
365
|
await consumeSsoReturn(oxy, {
|
|
361
366
|
isWeb: () => true,
|
|
@@ -365,20 +370,50 @@ describe('consumeSsoReturn', () => {
|
|
|
365
370
|
pathname: SSO_CALLBACK_PATH,
|
|
366
371
|
}),
|
|
367
372
|
history,
|
|
373
|
+
hardRedirect,
|
|
368
374
|
});
|
|
369
375
|
|
|
370
376
|
const last = history.calls[history.calls.length - 1];
|
|
371
377
|
expect(last?.[2]).toBe('/settings?tab=privacy');
|
|
372
378
|
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
379
|
+
expect(hardRedirect).not.toHaveBeenCalled();
|
|
373
380
|
});
|
|
374
381
|
|
|
375
|
-
it('
|
|
382
|
+
it('falls back to the app root on ok when NO dest is stored (soft replaceState to "/")', async () => {
|
|
383
|
+
const oxy = okExchange();
|
|
384
|
+
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
385
|
+
const history = makeHistory();
|
|
386
|
+
const dispatchPopState = jest.fn();
|
|
387
|
+
const hardRedirect = jest.fn();
|
|
388
|
+
|
|
389
|
+
const result = await consumeSsoReturn(oxy, {
|
|
390
|
+
isWeb: () => true,
|
|
391
|
+
storage,
|
|
392
|
+
location: makeLocation({
|
|
393
|
+
hash: '#oxy_sso=ok&code=c&state=s',
|
|
394
|
+
pathname: SSO_CALLBACK_PATH,
|
|
395
|
+
}),
|
|
396
|
+
history,
|
|
397
|
+
dispatchPopState,
|
|
398
|
+
hardRedirect,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
expect(result).toEqual(SESSION);
|
|
402
|
+
// First replaceState = fragment strip; last = root fallback.
|
|
403
|
+
const last = history.calls[history.calls.length - 1];
|
|
404
|
+
expect(last?.[2]).toBe('/');
|
|
405
|
+
expect(dispatchPopState).toHaveBeenCalledTimes(1);
|
|
406
|
+
expect(hardRedirect).not.toHaveBeenCalled();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('falls back to the app root on ok when the stored dest is cross-origin', async () => {
|
|
376
410
|
const oxy = okExchange();
|
|
377
411
|
const storage = makeStorage({
|
|
378
412
|
[ssoStateKey(ORIGIN)]: 's',
|
|
379
413
|
[ssoDestKey(ORIGIN)]: 'https://evil.example/phish',
|
|
380
414
|
});
|
|
381
415
|
const history = makeHistory();
|
|
416
|
+
const hardRedirect = jest.fn();
|
|
382
417
|
|
|
383
418
|
await consumeSsoReturn(oxy, {
|
|
384
419
|
isWeb: () => true,
|
|
@@ -388,14 +423,16 @@ describe('consumeSsoReturn', () => {
|
|
|
388
423
|
pathname: SSO_CALLBACK_PATH,
|
|
389
424
|
}),
|
|
390
425
|
history,
|
|
426
|
+
hardRedirect,
|
|
391
427
|
});
|
|
392
428
|
|
|
393
|
-
|
|
394
|
-
expect(
|
|
429
|
+
const last = history.calls[history.calls.length - 1];
|
|
430
|
+
expect(last?.[2]).toBe('/');
|
|
395
431
|
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
432
|
+
expect(hardRedirect).not.toHaveBeenCalled();
|
|
396
433
|
});
|
|
397
434
|
|
|
398
|
-
it('
|
|
435
|
+
it('falls back to the app root on ok when the stored dest is protocol-relative cross-origin', async () => {
|
|
399
436
|
const oxy = okExchange();
|
|
400
437
|
const storage = makeStorage({
|
|
401
438
|
[ssoStateKey(ORIGIN)]: 's',
|
|
@@ -413,7 +450,8 @@ describe('consumeSsoReturn', () => {
|
|
|
413
450
|
history,
|
|
414
451
|
});
|
|
415
452
|
|
|
416
|
-
|
|
453
|
+
const last = history.calls[history.calls.length - 1];
|
|
454
|
+
expect(last?.[2]).toBe('/');
|
|
417
455
|
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
418
456
|
});
|
|
419
457
|
|
|
@@ -424,6 +462,7 @@ describe('consumeSsoReturn', () => {
|
|
|
424
462
|
[ssoDestKey(ORIGIN)]: `${ORIGIN}/should-not-apply`,
|
|
425
463
|
});
|
|
426
464
|
const history = makeHistory();
|
|
465
|
+
const hardRedirect = jest.fn();
|
|
427
466
|
|
|
428
467
|
await consumeSsoReturn(oxy, {
|
|
429
468
|
isWeb: () => true,
|
|
@@ -434,15 +473,49 @@ describe('consumeSsoReturn', () => {
|
|
|
434
473
|
search: '?a=1',
|
|
435
474
|
}),
|
|
436
475
|
history,
|
|
476
|
+
hardRedirect,
|
|
437
477
|
});
|
|
438
478
|
|
|
439
479
|
// Only the fragment strip ran.
|
|
440
480
|
expect(history.calls).toHaveLength(1);
|
|
441
481
|
expect(history.calls[0]?.[2]).toBe('/already-here?a=1');
|
|
442
482
|
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
483
|
+
expect(hardRedirect).not.toHaveBeenCalled();
|
|
443
484
|
});
|
|
485
|
+
});
|
|
444
486
|
|
|
445
|
-
|
|
487
|
+
describe('non-ok hard redirect (never strand on the callback path)', () => {
|
|
488
|
+
it('hard-redirects to the app root on a "none" outcome with NO dest stored', async () => {
|
|
489
|
+
const oxy = okExchange();
|
|
490
|
+
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
491
|
+
const history = makeHistory();
|
|
492
|
+
const dispatchPopState = jest.fn();
|
|
493
|
+
const hardRedirect = jest.fn();
|
|
494
|
+
|
|
495
|
+
const result = await consumeSsoReturn(oxy, {
|
|
496
|
+
isWeb: () => true,
|
|
497
|
+
storage,
|
|
498
|
+
location: makeLocation({
|
|
499
|
+
hash: '#oxy_sso=none&state=s',
|
|
500
|
+
pathname: SSO_CALLBACK_PATH,
|
|
501
|
+
}),
|
|
502
|
+
history,
|
|
503
|
+
dispatchPopState,
|
|
504
|
+
hardRedirect,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
expect(result).toBeNull();
|
|
508
|
+
expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
|
|
509
|
+
expect(hardRedirect).toHaveBeenCalledTimes(1);
|
|
510
|
+
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/`);
|
|
511
|
+
// Soft restore is NOT used on a non-ok outcome.
|
|
512
|
+
expect(dispatchPopState).not.toHaveBeenCalled();
|
|
513
|
+
// Loop-breaker flags must still be set.
|
|
514
|
+
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
515
|
+
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('hard-redirects to a same-origin dest on a "none" outcome', async () => {
|
|
446
519
|
const oxy = okExchange();
|
|
447
520
|
const storage = makeStorage({
|
|
448
521
|
[ssoStateKey(ORIGIN)]: 's',
|
|
@@ -450,6 +523,7 @@ describe('consumeSsoReturn', () => {
|
|
|
450
523
|
});
|
|
451
524
|
const history = makeHistory();
|
|
452
525
|
const dispatchPopState = jest.fn();
|
|
526
|
+
const hardRedirect = jest.fn();
|
|
453
527
|
|
|
454
528
|
const result = await consumeSsoReturn(oxy, {
|
|
455
529
|
isWeb: () => true,
|
|
@@ -460,28 +534,27 @@ describe('consumeSsoReturn', () => {
|
|
|
460
534
|
}),
|
|
461
535
|
history,
|
|
462
536
|
dispatchPopState,
|
|
537
|
+
hardRedirect,
|
|
463
538
|
});
|
|
464
539
|
|
|
465
540
|
expect(result).toBeNull();
|
|
466
541
|
expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
expect(
|
|
542
|
+
expect(hardRedirect).toHaveBeenCalledTimes(1);
|
|
543
|
+
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/explore?x=1#sec`);
|
|
544
|
+
expect(dispatchPopState).not.toHaveBeenCalled();
|
|
470
545
|
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
471
|
-
expect(dispatchPopState).toHaveBeenCalledTimes(1);
|
|
472
|
-
// Loop-breaker flags must still be set.
|
|
473
546
|
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
474
547
|
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
475
548
|
});
|
|
476
549
|
|
|
477
|
-
it('
|
|
550
|
+
it('hard-redirects to a same-origin dest on an "error" outcome', async () => {
|
|
478
551
|
const oxy = okExchange();
|
|
479
552
|
const storage = makeStorage({
|
|
480
553
|
[ssoStateKey(ORIGIN)]: 's',
|
|
481
554
|
[ssoDestKey(ORIGIN)]: `${ORIGIN}/feed`,
|
|
482
555
|
});
|
|
483
556
|
const history = makeHistory();
|
|
484
|
-
const
|
|
557
|
+
const hardRedirect = jest.fn();
|
|
485
558
|
|
|
486
559
|
const result = await consumeSsoReturn(oxy, {
|
|
487
560
|
isWeb: () => true,
|
|
@@ -491,26 +564,25 @@ describe('consumeSsoReturn', () => {
|
|
|
491
564
|
pathname: SSO_CALLBACK_PATH,
|
|
492
565
|
}),
|
|
493
566
|
history,
|
|
494
|
-
|
|
567
|
+
hardRedirect,
|
|
495
568
|
});
|
|
496
569
|
|
|
497
570
|
expect(result).toBeNull();
|
|
498
|
-
|
|
499
|
-
expect(
|
|
571
|
+
expect(hardRedirect).toHaveBeenCalledTimes(1);
|
|
572
|
+
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/feed`);
|
|
500
573
|
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
501
|
-
expect(dispatchPopState).toHaveBeenCalledTimes(1);
|
|
502
574
|
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
503
575
|
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
504
576
|
});
|
|
505
577
|
|
|
506
|
-
it('
|
|
578
|
+
it('hard-redirects to the dest on a state mismatch (CSRF)', async () => {
|
|
507
579
|
const oxy = okExchange();
|
|
508
580
|
const storage = makeStorage({
|
|
509
581
|
[ssoStateKey(ORIGIN)]: 'expected',
|
|
510
582
|
[ssoDestKey(ORIGIN)]: `${ORIGIN}/notifications`,
|
|
511
583
|
});
|
|
512
584
|
const history = makeHistory();
|
|
513
|
-
const
|
|
585
|
+
const hardRedirect = jest.fn();
|
|
514
586
|
|
|
515
587
|
const result = await consumeSsoReturn(oxy, {
|
|
516
588
|
isWeb: () => true,
|
|
@@ -520,19 +592,100 @@ describe('consumeSsoReturn', () => {
|
|
|
520
592
|
pathname: SSO_CALLBACK_PATH,
|
|
521
593
|
}),
|
|
522
594
|
history,
|
|
523
|
-
|
|
595
|
+
hardRedirect,
|
|
524
596
|
});
|
|
525
597
|
|
|
526
598
|
expect(result).toBeNull();
|
|
527
599
|
expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
|
|
528
|
-
|
|
529
|
-
expect(
|
|
600
|
+
expect(hardRedirect).toHaveBeenCalledTimes(1);
|
|
601
|
+
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/notifications`);
|
|
530
602
|
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
531
|
-
expect(dispatchPopState).toHaveBeenCalledTimes(1);
|
|
532
603
|
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
533
604
|
});
|
|
534
605
|
|
|
535
|
-
it('
|
|
606
|
+
it('hard-redirects to the app root when ok carries a code but the exchange fails', async () => {
|
|
607
|
+
const boom = new Error('exchange failed');
|
|
608
|
+
const oxy = {
|
|
609
|
+
exchangeSsoCode: jest.fn(async () => {
|
|
610
|
+
throw boom;
|
|
611
|
+
}),
|
|
612
|
+
};
|
|
613
|
+
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
614
|
+
const history = makeHistory();
|
|
615
|
+
const hardRedirect = jest.fn();
|
|
616
|
+
const onExchangeError = jest.fn();
|
|
617
|
+
|
|
618
|
+
const result = await consumeSsoReturn(oxy, {
|
|
619
|
+
isWeb: () => true,
|
|
620
|
+
storage,
|
|
621
|
+
location: makeLocation({
|
|
622
|
+
hash: '#oxy_sso=ok&code=c&state=s',
|
|
623
|
+
pathname: SSO_CALLBACK_PATH,
|
|
624
|
+
}),
|
|
625
|
+
history,
|
|
626
|
+
hardRedirect,
|
|
627
|
+
onExchangeError,
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
expect(result).toBeNull();
|
|
631
|
+
expect(onExchangeError).toHaveBeenCalledWith(boom);
|
|
632
|
+
expect(hardRedirect).toHaveBeenCalledTimes(1);
|
|
633
|
+
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/`);
|
|
634
|
+
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('hard-redirects to the app root when ok carries a code but the exchange returns no sessionId', async () => {
|
|
638
|
+
const oxy = {
|
|
639
|
+
exchangeSsoCode: jest.fn(async () => ({ sessionId: '' }) as SessionLoginResponse),
|
|
640
|
+
};
|
|
641
|
+
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
642
|
+
const history = makeHistory();
|
|
643
|
+
const hardRedirect = jest.fn();
|
|
644
|
+
|
|
645
|
+
const result = await consumeSsoReturn(oxy, {
|
|
646
|
+
isWeb: () => true,
|
|
647
|
+
storage,
|
|
648
|
+
location: makeLocation({
|
|
649
|
+
hash: '#oxy_sso=ok&code=c&state=s',
|
|
650
|
+
pathname: SSO_CALLBACK_PATH,
|
|
651
|
+
}),
|
|
652
|
+
history,
|
|
653
|
+
hardRedirect,
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
expect(result).toBeNull();
|
|
657
|
+
expect(hardRedirect).toHaveBeenCalledTimes(1);
|
|
658
|
+
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/`);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('falls back to the app root on a "none" outcome when the dest is cross-origin', async () => {
|
|
662
|
+
const oxy = okExchange();
|
|
663
|
+
const storage = makeStorage({
|
|
664
|
+
[ssoStateKey(ORIGIN)]: 's',
|
|
665
|
+
[ssoDestKey(ORIGIN)]: 'https://evil.example/phish',
|
|
666
|
+
});
|
|
667
|
+
const history = makeHistory();
|
|
668
|
+
const hardRedirect = jest.fn();
|
|
669
|
+
|
|
670
|
+
const result = await consumeSsoReturn(oxy, {
|
|
671
|
+
isWeb: () => true,
|
|
672
|
+
storage,
|
|
673
|
+
location: makeLocation({
|
|
674
|
+
hash: '#oxy_sso=none&state=s',
|
|
675
|
+
pathname: SSO_CALLBACK_PATH,
|
|
676
|
+
}),
|
|
677
|
+
history,
|
|
678
|
+
hardRedirect,
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
expect(result).toBeNull();
|
|
682
|
+
// Never honour the cross-origin dest — fall back to the same-origin root.
|
|
683
|
+
expect(hardRedirect).toHaveBeenCalledTimes(1);
|
|
684
|
+
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/`);
|
|
685
|
+
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('does NOT hard-redirect on a "none" outcome when NOT on the callback path', async () => {
|
|
536
689
|
const oxy = okExchange();
|
|
537
690
|
const storage = makeStorage({
|
|
538
691
|
[ssoStateKey(ORIGIN)]: 's',
|
|
@@ -540,6 +693,7 @@ describe('consumeSsoReturn', () => {
|
|
|
540
693
|
});
|
|
541
694
|
const history = makeHistory();
|
|
542
695
|
const dispatchPopState = jest.fn();
|
|
696
|
+
const hardRedirect = jest.fn();
|
|
543
697
|
|
|
544
698
|
const result = await consumeSsoReturn(oxy, {
|
|
545
699
|
isWeb: () => true,
|
|
@@ -551,46 +705,52 @@ describe('consumeSsoReturn', () => {
|
|
|
551
705
|
}),
|
|
552
706
|
history,
|
|
553
707
|
dispatchPopState,
|
|
708
|
+
hardRedirect,
|
|
554
709
|
});
|
|
555
710
|
|
|
556
711
|
expect(result).toBeNull();
|
|
557
|
-
// Only the fragment strip ran — no
|
|
712
|
+
// Only the fragment strip ran — no navigation off the callback path.
|
|
558
713
|
expect(history.calls).toHaveLength(1);
|
|
559
714
|
expect(history.calls[0]?.[2]).toBe('/explore?a=1');
|
|
560
715
|
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
716
|
+
expect(hardRedirect).not.toHaveBeenCalled();
|
|
561
717
|
expect(dispatchPopState).not.toHaveBeenCalled();
|
|
562
718
|
});
|
|
563
719
|
|
|
564
|
-
it('
|
|
720
|
+
it('does NOT hard-redirect on an "ok" outcome when NOT on the callback path', async () => {
|
|
565
721
|
const oxy = okExchange();
|
|
566
722
|
const storage = makeStorage({
|
|
567
723
|
[ssoStateKey(ORIGIN)]: 's',
|
|
568
|
-
[ssoDestKey(ORIGIN)]:
|
|
724
|
+
[ssoDestKey(ORIGIN)]: `${ORIGIN}/should-not-apply`,
|
|
569
725
|
});
|
|
570
726
|
const history = makeHistory();
|
|
571
727
|
const dispatchPopState = jest.fn();
|
|
728
|
+
const hardRedirect = jest.fn();
|
|
572
729
|
|
|
573
730
|
const result = await consumeSsoReturn(oxy, {
|
|
574
731
|
isWeb: () => true,
|
|
575
732
|
storage,
|
|
576
733
|
location: makeLocation({
|
|
577
|
-
hash: '#oxy_sso=
|
|
578
|
-
pathname:
|
|
734
|
+
hash: '#oxy_sso=ok&code=c&state=s',
|
|
735
|
+
pathname: '/feed',
|
|
736
|
+
search: '?tab=home',
|
|
579
737
|
}),
|
|
580
738
|
history,
|
|
581
739
|
dispatchPopState,
|
|
740
|
+
hardRedirect,
|
|
582
741
|
});
|
|
583
742
|
|
|
584
|
-
expect(result).
|
|
585
|
-
// Only the fragment
|
|
743
|
+
expect(result).toEqual(SESSION);
|
|
744
|
+
// Only the fragment strip ran — no navigation off the callback path.
|
|
586
745
|
expect(history.calls).toHaveLength(1);
|
|
587
|
-
expect(
|
|
746
|
+
expect(history.calls[0]?.[2]).toBe('/feed?tab=home');
|
|
747
|
+
expect(hardRedirect).not.toHaveBeenCalled();
|
|
588
748
|
expect(dispatchPopState).not.toHaveBeenCalled();
|
|
589
749
|
});
|
|
590
750
|
});
|
|
591
751
|
|
|
592
752
|
describe('default dispatchPopState (jsdom)', () => {
|
|
593
|
-
it('fires a real popstate listener after
|
|
753
|
+
it('fires a real popstate listener after an "ok" dest restore on the callback path', async () => {
|
|
594
754
|
const oxy = okExchange();
|
|
595
755
|
const storage = makeStorage({
|
|
596
756
|
[ssoStateKey(ORIGIN)]: 's',
|
|
@@ -605,14 +765,14 @@ describe('consumeSsoReturn', () => {
|
|
|
605
765
|
isWeb: () => true,
|
|
606
766
|
storage,
|
|
607
767
|
location: makeLocation({
|
|
608
|
-
hash: '#oxy_sso=
|
|
768
|
+
hash: '#oxy_sso=ok&code=c&state=s',
|
|
609
769
|
pathname: SSO_CALLBACK_PATH,
|
|
610
770
|
}),
|
|
611
771
|
history,
|
|
612
772
|
// No injected dispatchPopState — exercise the real default.
|
|
613
773
|
});
|
|
614
774
|
|
|
615
|
-
expect(result).
|
|
775
|
+
expect(result).toEqual(SESSION);
|
|
616
776
|
const last = history.calls[history.calls.length - 1];
|
|
617
777
|
expect(last?.[2]).toBe('/dashboard?tab=home');
|
|
618
778
|
expect(onPopState).toHaveBeenCalledTimes(1);
|
|
@@ -621,4 +781,36 @@ describe('consumeSsoReturn', () => {
|
|
|
621
781
|
}
|
|
622
782
|
});
|
|
623
783
|
});
|
|
784
|
+
|
|
785
|
+
describe('default hardRedirect (feature-detected, never throws)', () => {
|
|
786
|
+
it('stays total (resolves null, sets loop-breaker flags) on a non-ok outcome with the real default seam', async () => {
|
|
787
|
+
const oxy = okExchange();
|
|
788
|
+
const storage = makeStorage({
|
|
789
|
+
[ssoStateKey(ORIGIN)]: 's',
|
|
790
|
+
[ssoDestKey(ORIGIN)]: '/dashboard',
|
|
791
|
+
});
|
|
792
|
+
const history = makeHistory();
|
|
793
|
+
|
|
794
|
+
// No injected `hardRedirect` — exercise the real default, which reads the
|
|
795
|
+
// jsdom `window.location.replace`. jsdom routes the resulting navigation
|
|
796
|
+
// to its virtual console (a "Not implemented" notice) and does NOT throw,
|
|
797
|
+
// so the function must remain total: resolve null and set the loop-breaker
|
|
798
|
+
// flags. (The injected-`hardRedirect` suite above asserts the precise
|
|
799
|
+
// target argument deterministically.)
|
|
800
|
+
await expect(
|
|
801
|
+
consumeSsoReturn(oxy, {
|
|
802
|
+
isWeb: () => true,
|
|
803
|
+
storage,
|
|
804
|
+
location: makeLocation({
|
|
805
|
+
hash: '#oxy_sso=none&state=s',
|
|
806
|
+
pathname: SSO_CALLBACK_PATH,
|
|
807
|
+
}),
|
|
808
|
+
history,
|
|
809
|
+
}),
|
|
810
|
+
).resolves.toBeNull();
|
|
811
|
+
|
|
812
|
+
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
813
|
+
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
814
|
+
});
|
|
815
|
+
});
|
|
624
816
|
});
|
|
@@ -31,6 +31,14 @@ export interface QuickAccount {
|
|
|
31
31
|
/** Minimal user shape accepted by display-name helpers. Avoids importing the full User type. */
|
|
32
32
|
export interface DisplayNameUserShape {
|
|
33
33
|
name?: string | { first?: string; last?: string; full?: string; [key: string]: unknown };
|
|
34
|
+
/**
|
|
35
|
+
* Pre-resolved display name as emitted by the server's `displayName` virtual
|
|
36
|
+
* (raw `/users/me` responses). NOTE: the server virtual resolves to
|
|
37
|
+
* `username || truncatedPublicKey || 'Anonymous'` — it does NOT compose the
|
|
38
|
+
* structured `name`. It is therefore preferred only AFTER a real structured
|
|
39
|
+
* name, so a first-name-only account never collapses to its username/key.
|
|
40
|
+
*/
|
|
41
|
+
displayName?: string;
|
|
34
42
|
username?: string;
|
|
35
43
|
publicKey?: string;
|
|
36
44
|
}
|
|
@@ -49,11 +57,16 @@ export const formatPublicKeyHandle = (publicKey: string): string => {
|
|
|
49
57
|
* Resolve a friendly display name for a user.
|
|
50
58
|
*
|
|
51
59
|
* Order of preference:
|
|
52
|
-
* 1. `name.full`, or composed `name.first name.last`
|
|
60
|
+
* 1. `name.full`, or composed `name.first name.last` (FIRST-NAME-ONLY SAFE —
|
|
61
|
+
* a user with only a first name resolves to that first name, never to the
|
|
62
|
+
* lowercase username; this is the exact drift bug the auth app hit).
|
|
53
63
|
* 2. `name` (when stored as a plain string)
|
|
54
|
-
* 3. `username`
|
|
55
|
-
*
|
|
56
|
-
*
|
|
64
|
+
* 3. `displayName` (server `displayName` virtual — `username || truncatedKey`).
|
|
65
|
+
* Placed AFTER the structured name on purpose: the server virtual ignores
|
|
66
|
+
* `name`, so preferring it first would re-introduce the first-only bug.
|
|
67
|
+
* 4. `username`
|
|
68
|
+
* 5. `Account 0x12345678…` (derived from publicKey, when present)
|
|
69
|
+
* 6. Translated fallback (e.g. "Unnamed")
|
|
57
70
|
*
|
|
58
71
|
* The translation key `common.unnamed` is used for the final fallback. If the
|
|
59
72
|
* caller does not pass a locale, the default English translation is used.
|
|
@@ -64,7 +77,7 @@ export const getAccountDisplayName = (
|
|
|
64
77
|
): string => {
|
|
65
78
|
if (!user) return translate(locale, 'common.unnamed');
|
|
66
79
|
|
|
67
|
-
const { name, username, publicKey } = user;
|
|
80
|
+
const { name, displayName, username, publicKey } = user;
|
|
68
81
|
|
|
69
82
|
if (name && typeof name === 'object') {
|
|
70
83
|
if (typeof name.full === 'string' && name.full.trim()) return name.full.trim();
|
|
@@ -76,6 +89,8 @@ export const getAccountDisplayName = (
|
|
|
76
89
|
return name.trim();
|
|
77
90
|
}
|
|
78
91
|
|
|
92
|
+
if (typeof displayName === 'string' && displayName.trim()) return displayName.trim();
|
|
93
|
+
|
|
79
94
|
if (typeof username === 'string' && username.trim()) return username.trim();
|
|
80
95
|
|
|
81
96
|
if (typeof publicKey === 'string' && publicKey.length > 0) {
|