@redacto.io/consent-sdk-react 9.4.0-beta.0 → 9.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.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +12 -12
  2. package/CHANGELOG.md +7 -0
  3. package/dist/privacy-center.d.mts +1 -0
  4. package/dist/privacy-center.d.ts +1 -0
  5. package/dist/privacy-center.js +1502 -1372
  6. package/dist/privacy-center.mjs +1185 -1055
  7. package/package.json +1 -1
  8. package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +5 -0
  9. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +6 -16
  10. package/src/RedactoPrivacyCenter/components/AppShell/usePrivacyCenterExitAction.ts +36 -0
  11. package/src/RedactoPrivacyCenter/components/PrivacyCenterSessionExpired.tsx +97 -0
  12. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +23 -2
  13. package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.session-expired.test.tsx +92 -0
  14. package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.tsx +15 -3
  15. package/src/RedactoPrivacyCenter/context/types.ts +4 -0
  16. package/src/RedactoPrivacyCenter/lib/utils.session-expired.test.ts +109 -0
  17. package/src/RedactoPrivacyCenter/lib/utils.ts +55 -79
  18. package/src/RedactoPrivacyCenter/locales/as/translation.json +2 -0
  19. package/src/RedactoPrivacyCenter/locales/bho/translation.json +2 -0
  20. package/src/RedactoPrivacyCenter/locales/bn/translation.json +2 -0
  21. package/src/RedactoPrivacyCenter/locales/brx/translation.json +2 -0
  22. package/src/RedactoPrivacyCenter/locales/doi/translation.json +2 -0
  23. package/src/RedactoPrivacyCenter/locales/en/translation.json +2 -0
  24. package/src/RedactoPrivacyCenter/locales/gom/translation.json +2 -0
  25. package/src/RedactoPrivacyCenter/locales/gu/translation.json +2 -0
  26. package/src/RedactoPrivacyCenter/locales/hi/translation.json +2 -0
  27. package/src/RedactoPrivacyCenter/locales/kn/translation.json +2 -0
  28. package/src/RedactoPrivacyCenter/locales/ks/translation.json +2 -0
  29. package/src/RedactoPrivacyCenter/locales/mai/translation.json +2 -0
  30. package/src/RedactoPrivacyCenter/locales/ml/translation.json +2 -0
  31. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +2 -0
  32. package/src/RedactoPrivacyCenter/locales/mr/translation.json +2 -0
  33. package/src/RedactoPrivacyCenter/locales/ne/translation.json +2 -0
  34. package/src/RedactoPrivacyCenter/locales/or/translation.json +2 -0
  35. package/src/RedactoPrivacyCenter/locales/pa/translation.json +2 -0
  36. package/src/RedactoPrivacyCenter/locales/sa/translation.json +2 -0
  37. package/src/RedactoPrivacyCenter/locales/sat/translation.json +2 -0
  38. package/src/RedactoPrivacyCenter/locales/sd/translation.json +2 -0
  39. package/src/RedactoPrivacyCenter/locales/ta/translation.json +2 -0
  40. package/src/RedactoPrivacyCenter/locales/te/translation.json +2 -0
  41. package/src/RedactoPrivacyCenter/locales/ur/translation.json +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redacto.io/consent-sdk-react",
3
- "version": "9.4.0-beta.0",
3
+ "version": "9.4.0",
4
4
  "main": "dist/index.cjs",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -22,6 +22,7 @@ import { PageHeader } from './PageHeader';
22
22
  import { PCLoader } from '../../ui';
23
23
  import { useDataAvailability } from '../../context/DataAvailabilityContext';
24
24
  import { PrivacyCenterEmptyState } from '../PrivacyCenterEmptyState';
25
+ import { PrivacyCenterSessionExpired } from '../PrivacyCenterSessionExpired';
25
26
  import type { PrivacyCenterLayoutProps } from '../types';
26
27
 
27
28
  export default function AppShell({
@@ -149,6 +150,10 @@ export default function AppShell({
149
150
  >
150
151
  <PCLoader variant="spinner" color="info" size="lg" />
151
152
  </div>
153
+ ) : status === 'session-expired' ? (
154
+ <div style={{ flex: 1, minWidth: 0, width: '100%' }}>
155
+ <PrivacyCenterSessionExpired onBack={onBack} />
156
+ </div>
152
157
  ) : status === 'empty' ? (
153
158
  <div style={{ flex: 1, minWidth: 0, width: '100%' }}>
154
159
  <PrivacyCenterEmptyState />
@@ -1,10 +1,10 @@
1
- import { useCallback, useEffect, useState, type CSSProperties } from 'react';
2
- import { useTranslation } from 'react-i18next';
3
- import { ArrowLeft, ChevronDown, LogOut } from 'lucide-react';
1
+ import { useEffect, useState, type CSSProperties } from 'react';
2
+ import { ChevronDown } from 'lucide-react';
4
3
  import { PCAvatar, PCPopover } from '../../ui';
5
4
  import { useBranding } from '../../context/BrandingContext';
6
5
  import { useOrganization } from '../../context/OrganizationContext';
7
- import { getVar, signOutPrivacyCenter } from '../../lib/utils';
6
+ import { getVar } from '../../lib/utils';
7
+ import { usePrivacyCenterExitAction } from './usePrivacyCenterExitAction';
8
8
 
9
9
  type UserMenuProps = {
10
10
  onBack?: 'back' | 'signout';
@@ -30,7 +30,6 @@ function initialsFromEmail(email: string): string {
30
30
  }
31
31
 
32
32
  export function UserMenu({ onBack }: UserMenuProps) {
33
- const { t } = useTranslation();
34
33
  const branding = useBranding();
35
34
  const { organization } = useOrganization();
36
35
  const [email, setEmail] = useState('');
@@ -39,17 +38,8 @@ export function UserMenu({ onBack }: UserMenuProps) {
39
38
  setEmail(readEmail());
40
39
  }, []);
41
40
 
42
- const handleAction = useCallback(() => {
43
- if (onBack === 'back') {
44
- window.history.back();
45
- } else if (onBack === 'signout') {
46
- signOutPrivacyCenter();
47
- window.history.back();
48
- }
49
- }, [onBack]);
50
-
51
- const actionLabel = onBack === 'back' ? t('back') : t('signOut');
52
- const ActionIcon = onBack === 'back' ? ArrowLeft : LogOut;
41
+ const { handleAction, label: actionLabel, Icon: ActionIcon } =
42
+ usePrivacyCenterExitAction(onBack);
53
43
 
54
44
  const displayName = branding?.brand_name || organization?.name || '';
55
45
  const triggerAvatarName = email ? initialsFromEmail(email) : displayName || '?';
@@ -0,0 +1,36 @@
1
+ import { useCallback } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { ArrowLeft, LogOut, type LucideIcon } from 'lucide-react';
4
+ import { signOutPrivacyCenter } from '../../lib/utils';
5
+
6
+ /**
7
+ * Shared "exit the Privacy Center" action, driven by the consumer's
8
+ * `onBack` prop ('back' | 'signout'). Centralised so the top-bar UserMenu item
9
+ * and the session-expired screen's CTA stay in lockstep — same label, icon, and
10
+ * behaviour. `enabled` is false when the consumer passed no `onBack`, so callers
11
+ * render no affordance (and nothing ever fires automatically).
12
+ */
13
+ export function usePrivacyCenterExitAction(onBack?: 'back' | 'signout'): {
14
+ enabled: boolean;
15
+ handleAction: () => void;
16
+ label: string;
17
+ Icon: LucideIcon;
18
+ } {
19
+ const { t } = useTranslation();
20
+
21
+ const handleAction = useCallback(() => {
22
+ if (onBack === 'back') {
23
+ window.history.back();
24
+ } else if (onBack === 'signout') {
25
+ signOutPrivacyCenter();
26
+ window.history.back();
27
+ }
28
+ }, [onBack]);
29
+
30
+ return {
31
+ enabled: !!onBack,
32
+ handleAction,
33
+ label: onBack === 'back' ? t('back') : t('signOut'),
34
+ Icon: onBack === 'back' ? ArrowLeft : LogOut,
35
+ };
36
+ }
@@ -0,0 +1,97 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import { Clock } from 'lucide-react';
3
+ import { PCButton, PCText, PCTitle } from '../ui';
4
+ import { pcStyles } from '../styles/pcStyles';
5
+ import { getVar } from '../lib/utils';
6
+ import { usePrivacyCenterExitAction } from './AppShell/usePrivacyCenterExitAction';
7
+
8
+ type PrivacyCenterSessionExpiredProps = {
9
+ onBack?: 'back' | 'signout';
10
+ };
11
+
12
+ /**
13
+ * Terminal screen shown when the session has expired (the refresh token is
14
+ * dead). Purely presentational — no data fetching happens behind it. Mirrors
15
+ * PrivacyCenterEmptyState's layout. The exit is MANUAL: an inline CTA (and the
16
+ * top-bar UserMenu item) reuse the consumer's existing `onBack` prop; nothing
17
+ * navigates automatically. When `onBack` is absent, only the message shows.
18
+ */
19
+ export function PrivacyCenterSessionExpired({
20
+ onBack,
21
+ }: PrivacyCenterSessionExpiredProps) {
22
+ const { t } = useTranslation();
23
+ const { enabled, handleAction, label, Icon } = usePrivacyCenterExitAction(onBack);
24
+
25
+ return (
26
+ <div
27
+ style={{
28
+ ...pcStyles.flex,
29
+ ...pcStyles.flexCol,
30
+ ...pcStyles.itemsCenter,
31
+ ...pcStyles.justifyCenter,
32
+ ...pcStyles.textCenter,
33
+ width: '100%',
34
+ maxWidth: 460,
35
+ margin: '0 auto',
36
+ minHeight: 'min(62vh, 540px)',
37
+ paddingTop: getVar('--pc-spacing-2xl'),
38
+ paddingBottom: getVar('--pc-spacing-2xl'),
39
+ gap: getVar('--pc-spacing-xl'),
40
+ }}
41
+ >
42
+ <div
43
+ aria-hidden="true"
44
+ style={{
45
+ display: 'flex',
46
+ alignItems: 'center',
47
+ justifyContent: 'center',
48
+ width: 168,
49
+ height: 168,
50
+ borderRadius: '50%',
51
+ background:
52
+ 'color-mix(in srgb, var(--pc-primary) 8%, var(--pc-bg-shell))',
53
+ flexShrink: 0,
54
+ }}
55
+ >
56
+ <Clock size={72} strokeWidth={1.5} color={getVar('--pc-primary')} />
57
+ </div>
58
+
59
+ <div
60
+ style={{
61
+ ...pcStyles.flex,
62
+ ...pcStyles.flexCol,
63
+ ...pcStyles.itemsCenter,
64
+ gap: getVar('--pc-spacing-sm'),
65
+ }}
66
+ >
67
+ <PCTitle as="h2" style={{ margin: 0 }}>
68
+ {t('sessionExpiredTitle')}
69
+ </PCTitle>
70
+ <PCText
71
+ as="p"
72
+ size="base"
73
+ color="secondary"
74
+ align="center"
75
+ style={{ margin: 0, maxWidth: 400 }}
76
+ >
77
+ {t('sessionExpiredDescription')}
78
+ </PCText>
79
+ </div>
80
+
81
+ {enabled && (
82
+ <PCButton
83
+ variant="primary"
84
+ onClick={handleAction}
85
+ style={{
86
+ display: 'inline-flex',
87
+ alignItems: 'center',
88
+ gap: 8,
89
+ }}
90
+ >
91
+ <Icon size={16} strokeWidth={2} />
92
+ <span>{label}</span>
93
+ </PCButton>
94
+ )}
95
+ </div>
96
+ );
97
+ }
@@ -1,5 +1,5 @@
1
1
  import { createContext, useContext, useState, useEffect, useCallback, useMemo, type ReactNode } from 'react';
2
- import { setRefreshTokenCookie, decodeJwtToken } from '../lib/utils';
2
+ import { setRefreshTokenCookie, decodeJwtToken, setSessionExpiredListener } from '../lib/utils';
3
3
  import type { AuthContextValue } from './types';
4
4
 
5
5
  export type { AuthContextValue } from './types';
@@ -80,6 +80,21 @@ export const AuthProvider = ({
80
80
  return initialRefreshToken;
81
81
  });
82
82
 
83
+ // Terminal "session expired" flag. Set by utils' refresh routines (via the
84
+ // module listener registered below) when the refresh token is dead; reset when
85
+ // a new refresh token appears (re-auth) so the breaker and the screen recover
86
+ // together. Drives the shared session-expired screen and gates all fetching.
87
+ const [sessionExpired, setSessionExpired] = useState(false);
88
+ const markSessionExpired = useCallback(() => setSessionExpired(true), []);
89
+
90
+ // Bridge the module-level refresh routines to React state. utils calls the
91
+ // registered listener on terminal failure; this is the same single-instance
92
+ // singleton model as the OTP refresh circuit breaker.
93
+ useEffect(() => {
94
+ setSessionExpiredListener(markSessionExpired);
95
+ return () => setSessionExpiredListener(null);
96
+ }, [markSessionExpired]);
97
+
83
98
  // Re-sync when props change (for token refresh scenarios)
84
99
  useEffect(() => {
85
100
  if (initialAccessToken && typeof initialAccessToken === 'string' && initialAccessToken.trim() !== '') {
@@ -94,6 +109,9 @@ export const AuthProvider = ({
94
109
  useEffect(() => {
95
110
  if (initialRefreshToken && typeof initialRefreshToken === 'string' && initialRefreshToken.trim() !== '') {
96
111
  setRefreshToken(initialRefreshToken);
112
+ // A fresh refresh token means the user re-authenticated — clear the
113
+ // terminal state (the breaker auto-re-arms on the next ensureValidToken).
114
+ setSessionExpired(false);
97
115
  if (typeof window !== 'undefined') {
98
116
  setRefreshTokenCookie(initialRefreshToken);
99
117
  }
@@ -106,6 +124,8 @@ export const AuthProvider = ({
106
124
  if (newRefreshToken) {
107
125
  setRefreshToken(newRefreshToken);
108
126
  }
127
+ // A successful refresh clears any prior terminal state.
128
+ setSessionExpired(false);
109
129
  // Also update storage
110
130
  if (typeof window !== 'undefined') {
111
131
  sessionStorage.setItem('access_token', newAccessToken);
@@ -123,7 +143,8 @@ export const AuthProvider = ({
123
143
  refreshToken,
124
144
  onError,
125
145
  updateTokens,
126
- }), [accessToken, refreshToken, onError, updateTokens]);
146
+ sessionExpired,
147
+ }), [accessToken, refreshToken, onError, updateTokens, sessionExpired]);
127
148
 
128
149
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
129
150
  };
@@ -0,0 +1,92 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { render, screen, waitFor, cleanup } from '@testing-library/react';
3
+ import {
4
+ DataAvailabilityProvider,
5
+ useDataAvailability,
6
+ } from './DataAvailabilityContext';
7
+
8
+ /**
9
+ * Regression: when the session is terminally expired, the DataAvailabilityProvider
10
+ * must NOT fire any data fetch on mount — it goes straight to the 'session-expired'
11
+ * status so the shell renders the terminal screen instead of (fail-open) content.
12
+ */
13
+
14
+ // Mutable auth state shared with the hoisted mock so each test can flip it.
15
+ const auth = vi.hoisted(() => ({
16
+ current: {
17
+ accessToken: 'stale-access-token',
18
+ refreshToken: 'dead-refresh-token',
19
+ onError: () => false,
20
+ updateTokens: () => {},
21
+ sessionExpired: true,
22
+ },
23
+ }));
24
+
25
+ vi.mock('../lib/api', () => ({
26
+ fetchUserConsents: vi.fn(() => Promise.resolve(null)),
27
+ fetchActivities: vi.fn(() => Promise.resolve(null)),
28
+ fetchCaseHistory: vi.fn(() => Promise.resolve(null)),
29
+ }));
30
+
31
+ vi.mock('./AuthContext', () => ({ useAuth: () => auth.current }));
32
+ vi.mock('./OrganizationContext', () => ({
33
+ useOrganization: () => ({
34
+ organization: { uuid: 'org-uuid', workspaces: [{ uuid: 'ws-uuid' }] },
35
+ }),
36
+ }));
37
+ vi.mock('./BaseUrlContext', () => ({ useBaseUrl: () => ({ consentClient: {} }) }));
38
+ vi.mock('../lib/useCurrentLanguage', () => ({
39
+ useCurrentLanguage: () => ({ langParam: 'en' }),
40
+ }));
41
+
42
+ import { fetchUserConsents, fetchActivities, fetchCaseHistory } from '../lib/api';
43
+
44
+ function StatusProbe() {
45
+ const { status } = useDataAvailability();
46
+ return <div data-testid="status">{status}</div>;
47
+ }
48
+
49
+ describe('DataAvailabilityProvider — session-expired gate', () => {
50
+ beforeEach(() => {
51
+ vi.clearAllMocks();
52
+ sessionStorage.clear();
53
+ sessionStorage.setItem('email', 'user@example.com');
54
+ auth.current = {
55
+ accessToken: 'stale-access-token',
56
+ refreshToken: 'dead-refresh-token',
57
+ onError: () => false,
58
+ updateTokens: () => {},
59
+ sessionExpired: true,
60
+ };
61
+ });
62
+
63
+ afterEach(() => cleanup());
64
+
65
+ it('does not fetch and reports session-expired when the session is expired', () => {
66
+ auth.current.sessionExpired = true;
67
+
68
+ render(
69
+ <DataAvailabilityProvider>
70
+ <StatusProbe />
71
+ </DataAvailabilityProvider>,
72
+ );
73
+
74
+ expect(screen.getByTestId('status').textContent).toBe('session-expired');
75
+ expect(fetchUserConsents).not.toHaveBeenCalled();
76
+ expect(fetchActivities).not.toHaveBeenCalled();
77
+ expect(fetchCaseHistory).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('does fetch when the session is NOT expired (gate is meaningful)', async () => {
81
+ auth.current.sessionExpired = false;
82
+
83
+ render(
84
+ <DataAvailabilityProvider>
85
+ <StatusProbe />
86
+ </DataAvailabilityProvider>,
87
+ );
88
+
89
+ await waitFor(() => expect(fetchUserConsents).toHaveBeenCalled());
90
+ expect(fetchActivities).toHaveBeenCalled();
91
+ });
92
+ });
@@ -14,7 +14,11 @@ import { useOrganization } from './OrganizationContext';
14
14
  import { useAuth } from './AuthContext';
15
15
  import { useBaseUrl } from './BaseUrlContext';
16
16
 
17
- export type DataAvailabilityStatus = 'checking' | 'has-data' | 'empty';
17
+ export type DataAvailabilityStatus =
18
+ | 'checking'
19
+ | 'has-data'
20
+ | 'empty'
21
+ | 'session-expired';
18
22
 
19
23
  type DataAvailabilityContextValue = {
20
24
  status: DataAvailabilityStatus;
@@ -36,7 +40,7 @@ const DataAvailabilityContext = createContext<DataAvailabilityContextValue | nul
36
40
  */
37
41
  export const DataAvailabilityProvider = ({ children }: { children: ReactNode }) => {
38
42
  const { organization } = useOrganization();
39
- const { accessToken, refreshToken, onError, updateTokens } = useAuth();
43
+ const { accessToken, refreshToken, onError, updateTokens, sessionExpired } = useAuth();
40
44
  const { consentClient } = useBaseUrl();
41
45
  const { langParam } = useCurrentLanguage();
42
46
 
@@ -141,10 +145,18 @@ export const DataAvailabilityProvider = ({ children }: { children: ReactNode })
141
145
  }, [organizationId, workspaceId, authContext, consentClient, langParam]);
142
146
 
143
147
  useEffect(() => {
148
+ // Terminal session: never fetch. Bump the run id so any in-flight runCheck
149
+ // resolves into a no-op (its fail-open path would otherwise flip the status
150
+ // back to 'has-data' and remount the content), then show the expired screen.
151
+ if (sessionExpired) {
152
+ runIdRef.current += 1;
153
+ setStatus('session-expired');
154
+ return;
155
+ }
144
156
  if (organizationId && workspaceId && accessToken) {
145
157
  void runCheck();
146
158
  }
147
- }, [organizationId, workspaceId, accessToken, runCheck]);
159
+ }, [organizationId, workspaceId, accessToken, runCheck, sessionExpired]);
148
160
 
149
161
  const value = useMemo<DataAvailabilityContextValue>(
150
162
  () => ({ status }),
@@ -6,6 +6,10 @@ export type AuthContextValue = Readonly<{
6
6
  onError: (error: Error) => boolean;
7
7
  // Internal: updates tokens after refresh
8
8
  updateTokens: (accessToken: string, refreshToken?: string) => void;
9
+ // Terminal: true once a refresh failed because the refresh token is
10
+ // expired/invalid. Consumers gate fetching/rendering on this to show the
11
+ // shared "session expired" screen and stop all further work.
12
+ sessionExpired: boolean;
9
13
  }>;
10
14
 
11
15
  export type BaseUrlContextValue = Readonly<{
@@ -0,0 +1,109 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ ensureValidToken,
4
+ resetRefreshCircuit,
5
+ setSessionExpiredListener,
6
+ } from './utils';
7
+ import { ApiError } from '../api/fetcher';
8
+
9
+ /**
10
+ * Regression tests for the terminal "session expired" signal.
11
+ *
12
+ * When the refresh token is expired/invalid, the refresh endpoint must be hit at
13
+ * most once (the circuit breaker) AND the React tree must be told the session is
14
+ * over exactly once (so it can show the terminal screen and stop all work). A
15
+ * transient (5xx/network) failure must NOT raise the terminal signal — those are
16
+ * retryable and the user is still signed in.
17
+ */
18
+
19
+ // Minimal JWT with a chosen `exp` (only the base64 payload is read).
20
+ const base64url = (obj: unknown) => btoa(JSON.stringify(obj)).replace(/=+$/, '');
21
+ const makeJwt = (expSeconds: number) =>
22
+ `${base64url({ alg: 'HS256', typ: 'JWT' })}.${base64url({ exp: expSeconds })}.sig`;
23
+
24
+ // `exp` in 1970 → always expired, so ensureValidToken always takes the refresh path.
25
+ const EXPIRED_ACCESS_TOKEN = makeJwt(1_000);
26
+ const ORG = 'org-uuid';
27
+ const WS = 'ws-uuid';
28
+
29
+ const makeConsentClient = (
30
+ refreshOtpToken: (orgId: string, wsId: string, refreshToken: string) => Promise<unknown>,
31
+ ) => ({ refreshOtpToken: vi.fn(refreshOtpToken) }) as any;
32
+
33
+ const makeAuthContext = (refreshToken: string) => ({
34
+ accessToken: EXPIRED_ACCESS_TOKEN,
35
+ refreshToken,
36
+ // Return false (host handles its own navigation). The terminal signal must fire
37
+ // regardless of this return value — that is the behaviour under test.
38
+ onError: vi.fn(() => false),
39
+ updateTokens: vi.fn(),
40
+ });
41
+
42
+ describe('ensureValidToken — terminal session-expired signal', () => {
43
+ beforeEach(() => {
44
+ resetRefreshCircuit();
45
+ setSessionExpiredListener(null);
46
+ sessionStorage.clear();
47
+ vi.clearAllMocks();
48
+ });
49
+
50
+ afterEach(() => {
51
+ setSessionExpiredListener(null);
52
+ vi.useRealTimers();
53
+ });
54
+
55
+ it('fires the session-expired listener exactly once on a terminal failure', async () => {
56
+ const onExpired = vi.fn();
57
+ setSessionExpiredListener(onExpired);
58
+
59
+ const client = makeConsentClient(() =>
60
+ Promise.reject(new ApiError('Refresh token has expired', { status: 401 })),
61
+ );
62
+ const authContext = makeAuthContext('dead-refresh-token');
63
+
64
+ // Many triggers (pollers + API calls + effects) against the same dead token.
65
+ const results: Array<string | null> = [];
66
+ for (let i = 0; i < 10; i++) {
67
+ results.push(await ensureValidToken(ORG, WS, undefined, authContext, client));
68
+ }
69
+
70
+ expect(results.every((r) => r === null)).toBe(true);
71
+ // Breaker: the dead refresh token is submitted at most once.
72
+ expect(client.refreshOtpToken).toHaveBeenCalledTimes(1);
73
+ // Terminal signal raised once, even though onError returned false.
74
+ expect(onExpired).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ it('does NOT fire the listener for a transient (5xx) failure', async () => {
78
+ vi.useFakeTimers();
79
+ vi.setSystemTime(new Date('2026-06-22T11:00:00Z'));
80
+
81
+ const onExpired = vi.fn();
82
+ setSessionExpiredListener(onExpired);
83
+
84
+ const client = makeConsentClient(() =>
85
+ Promise.reject(new ApiError('Service Unavailable', { status: 503 })),
86
+ );
87
+ const authContext = makeAuthContext('flaky-refresh-token');
88
+
89
+ await ensureValidToken(ORG, WS, undefined, authContext, client);
90
+ await ensureValidToken(ORG, WS, undefined, authContext, client);
91
+
92
+ // One attempt then backoff suppresses the rest — and the session is NOT
93
+ // declared terminally expired (the token may still be valid).
94
+ expect(client.refreshOtpToken).toHaveBeenCalledTimes(1);
95
+ expect(onExpired).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it('tolerates a terminal failure with no listener registered (no throw)', async () => {
99
+ const client = makeConsentClient(() =>
100
+ Promise.reject(new ApiError('Refresh token has expired', { status: 401 })),
101
+ );
102
+ const authContext = makeAuthContext('dead-refresh-token');
103
+
104
+ await expect(
105
+ ensureValidToken(ORG, WS, undefined, authContext, client),
106
+ ).resolves.toBeNull();
107
+ expect(client.refreshOtpToken).toHaveBeenCalledTimes(1);
108
+ });
109
+ });