@redacto.io/consent-sdk-react 10.0.0-beta.0 → 10.0.0-beta.11
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/.turbo/turbo-build.log +22 -25
- package/CHANGELOG.md +123 -0
- package/README.md +78 -0
- package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
- package/dist/index.d.mts +43 -8
- package/dist/index.d.ts +43 -8
- package/dist/index.js +345 -237
- package/dist/index.mjs +344 -238
- package/dist/privacy-center.d.mts +113 -37
- package/dist/privacy-center.d.ts +113 -37
- package/dist/privacy-center.js +2317 -1910
- package/dist/privacy-center.mjs +2245 -1835
- package/package.json +1 -1
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +87 -0
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +27 -0
- package/src/RedactoNoticeAssisted/api/index.ts +7 -1
- package/src/RedactoNoticeAssisted/api/types.ts +2 -0
- package/src/RedactoNoticeAssisted/i18n.ts +0 -55
- package/src/RedactoNoticeAssisted/types.ts +2 -0
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +42 -8
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +75 -33
- package/src/RedactoNoticeConsent/api/index.ts +90 -177
- package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
- package/src/RedactoNoticeConsent/api/types.ts +60 -44
- package/src/RedactoNoticeConsent/language-codes.ts +0 -2
- package/src/RedactoNoticeConsent/types.ts +29 -3
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +86 -5
- package/src/RedactoNoticeConsentInline/api/index.ts +14 -5
- package/src/RedactoNoticeConsentInline/api/notice-read-routing.test.ts +72 -0
- package/src/RedactoNoticeConsentInline/api/types.ts +4 -1
- package/src/RedactoNoticeConsentInline/types.ts +1 -1
- package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +8 -5
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +38 -3
- package/src/RedactoPrivacyCenter/api/actions.ts +13 -5
- package/src/RedactoPrivacyCenter/api/client.ts +216 -39
- package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
- package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +28 -0
- package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +107 -76
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
- package/src/RedactoPrivacyCenter/components/Form.tsx +53 -24
- package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -1
- package/src/RedactoPrivacyCenter/components/SelectUser.tsx +95 -0
- package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +195 -0
- package/src/RedactoPrivacyCenter/components/constant.ts +11 -0
- package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
- package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
- package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +1 -1
- package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +15 -0
- package/src/RedactoPrivacyCenter/context/types.ts +39 -1
- package/src/RedactoPrivacyCenter/lib/api.ts +3 -0
- package/src/RedactoPrivacyCenter/lib/constants.ts +12 -6
- package/src/RedactoPrivacyCenter/lib/locales.ts +0 -3
- package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
- package/src/RedactoPrivacyCenter/lib/types.ts +78 -2
- package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
- package/src/RedactoPrivacyCenter/locales/as/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/en/translation.json +8 -1
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/or/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/te/translation.json +7 -1
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +7 -1
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +112 -2
- package/src/RedactoPrivacyCenter/styles/pcStyles.ts +17 -0
- package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +83 -0
- package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +33 -9
- package/src/shared/notice-consent-status.ts +190 -0
- package/src/shared/sandbox.ts +6 -0
- package/tests/Form.test.tsx +190 -7
- package/tests/RevokeWarningMessage.test.tsx +151 -0
- package/tests/consentClient.test.ts +217 -0
- package/tests/manageConsentServer.test.ts +38 -0
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +0 -334
package/tests/Form.test.tsx
CHANGED
|
@@ -140,13 +140,16 @@ vi.mock('../src/RedactoPrivacyCenter/context/AuthContext', () => {
|
|
|
140
140
|
return { useAuth: () => auth };
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
});
|
|
143
|
+
// Mutable so individual tests can drop `ledgerBaseUrl` to exercise the legacy
|
|
144
|
+
// (no-ledger) mode; beforeEach restores the ledger default.
|
|
145
|
+
const baseUrlStub = vi.hoisted(() => ({
|
|
146
|
+
consentClient: { get: () => Promise.resolve(null), post: () => Promise.resolve(null) },
|
|
147
|
+
baseUrl: 'https://consent.test',
|
|
148
|
+
ledgerBaseUrl: 'https://ledger.test/consent-ledger' as string | undefined,
|
|
149
|
+
}));
|
|
150
|
+
vi.mock('../src/RedactoPrivacyCenter/context/BaseUrlContext', () => ({
|
|
151
|
+
useBaseUrl: () => baseUrlStub,
|
|
152
|
+
}));
|
|
150
153
|
|
|
151
154
|
vi.mock('../src/RedactoPrivacyCenter/context/OrganizationContext', () => {
|
|
152
155
|
const org = {
|
|
@@ -481,6 +484,8 @@ describe('Form (P2-SDK-3)', () => {
|
|
|
481
484
|
beforeEach(() => {
|
|
482
485
|
createCaseRequest.mockReset();
|
|
483
486
|
getFormData.mockReset();
|
|
487
|
+
fetchUserConsents.mockReset();
|
|
488
|
+
baseUrlStub.ledgerBaseUrl = 'https://ledger.test/consent-ledger';
|
|
484
489
|
sessionStorage.clear();
|
|
485
490
|
});
|
|
486
491
|
|
|
@@ -519,6 +524,184 @@ describe('Form (P2-SDK-3)', () => {
|
|
|
519
524
|
expect(submitButton.disabled).toBe(true);
|
|
520
525
|
});
|
|
521
526
|
|
|
527
|
+
it('runs the picker overlay against the legacy endpoint when no ledgerBaseUrl is configured', async () => {
|
|
528
|
+
// `getUserConsents` serves the same grouped payload from either backend
|
|
529
|
+
// (ledger `/public/...` or Python `/dsar/privacy-center/...`), so the
|
|
530
|
+
// overlay must run in both modes — Python's form/data no longer emits
|
|
531
|
+
// product groups, and skipping the read leaves the picker empty.
|
|
532
|
+
baseUrlStub.ledgerBaseUrl = undefined;
|
|
533
|
+
setupSession();
|
|
534
|
+
fetchUserConsents.mockResolvedValue(null);
|
|
535
|
+
getFormData.mockResolvedValue(
|
|
536
|
+
makeFormData({
|
|
537
|
+
direct_groups: [
|
|
538
|
+
makeGroup(),
|
|
539
|
+
makeGroup({
|
|
540
|
+
product_uuid: 'prod-direct-2',
|
|
541
|
+
product_name: 'Acme Rewards',
|
|
542
|
+
purposes: [basePurpose('purp-2', 'Analytics')],
|
|
543
|
+
}),
|
|
544
|
+
],
|
|
545
|
+
}),
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
render(<Form onCreateRequest={vi.fn()} />);
|
|
549
|
+
await waitFor(() => expect(getFormData).toHaveBeenCalled());
|
|
550
|
+
await waitFor(() => expect(fetchUserConsents).toHaveBeenCalled());
|
|
551
|
+
|
|
552
|
+
pickRequestType(RequestTypeEnum.ERASURE);
|
|
553
|
+
await waitFor(() => {
|
|
554
|
+
expect(screen.getByText('Acme Loyalty')).toBeInTheDocument();
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('runs the ledger picker overlay when ledgerBaseUrl is configured', async () => {
|
|
559
|
+
setupSession();
|
|
560
|
+
getFormData.mockResolvedValue(
|
|
561
|
+
makeFormData({ direct_groups: [makeGroup()] }),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
render(<Form onCreateRequest={vi.fn()} />);
|
|
565
|
+
await waitFor(() => expect(getFormData).toHaveBeenCalled());
|
|
566
|
+
|
|
567
|
+
await waitFor(() => expect(fetchUserConsents).toHaveBeenCalled());
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('keeps the form/data groups when user-consents comes back with no groups', async () => {
|
|
571
|
+
// A successful-but-empty read must never overwrite groups form/data did
|
|
572
|
+
// supply — that turned a populated picker into "No products available".
|
|
573
|
+
setupSession();
|
|
574
|
+
fetchUserConsents.mockResolvedValue({ direct: [], nominated: [] });
|
|
575
|
+
getFormData.mockResolvedValue(
|
|
576
|
+
makeFormData({
|
|
577
|
+
direct_groups: [
|
|
578
|
+
makeGroup(),
|
|
579
|
+
makeGroup({
|
|
580
|
+
product_uuid: 'prod-direct-2',
|
|
581
|
+
product_name: 'Acme Rewards',
|
|
582
|
+
purposes: [basePurpose('purp-2', 'Analytics')],
|
|
583
|
+
}),
|
|
584
|
+
],
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
render(<Form onCreateRequest={vi.fn()} />);
|
|
589
|
+
await waitFor(() => expect(fetchUserConsents).toHaveBeenCalled());
|
|
590
|
+
|
|
591
|
+
pickRequestType(RequestTypeEnum.ERASURE);
|
|
592
|
+
await waitFor(() => {
|
|
593
|
+
expect(screen.getByText('Acme Loyalty')).toBeInTheDocument();
|
|
594
|
+
});
|
|
595
|
+
expect(screen.getByText('Acme Rewards')).toBeInTheDocument();
|
|
596
|
+
expect(screen.queryByText('No products available')).not.toBeInTheDocument();
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('falls back to the legacy flat purposes list when the backend exposes no product groups', async () => {
|
|
600
|
+
// Pre-grouping backends return purposes without any product grouping.
|
|
601
|
+
// Those consents must stay reachable under one synthetic group instead
|
|
602
|
+
// of the picker claiming the principal has none.
|
|
603
|
+
setupSession();
|
|
604
|
+
fetchUserConsents.mockResolvedValue(null);
|
|
605
|
+
getFormData.mockResolvedValue(
|
|
606
|
+
makeFormData({
|
|
607
|
+
direct_groups: [],
|
|
608
|
+
nominated_groups: [],
|
|
609
|
+
purposes: [{ purpose: basePurpose('purp-flat', 'Marketing') }],
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
render(<Form onCreateRequest={vi.fn()} />);
|
|
614
|
+
await waitFor(() => expect(getFormData).toHaveBeenCalled());
|
|
615
|
+
|
|
616
|
+
pickRequestType(RequestTypeEnum.ACCESS);
|
|
617
|
+
|
|
618
|
+
await waitFor(() => {
|
|
619
|
+
expect(screen.getByText('Your data')).toBeInTheDocument();
|
|
620
|
+
});
|
|
621
|
+
expect(screen.getByText('Marketing')).toBeInTheDocument();
|
|
622
|
+
expect(screen.queryByText('No products available')).not.toBeInTheDocument();
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('keeps the empty state when the flat purposes carry no data elements', async () => {
|
|
626
|
+
// Nothing selectable survives `buildGroupPurposes`, so the synthetic group
|
|
627
|
+
// would render an unusable panel — the empty state is the honest answer.
|
|
628
|
+
setupSession();
|
|
629
|
+
fetchUserConsents.mockResolvedValue(null);
|
|
630
|
+
getFormData.mockResolvedValue(
|
|
631
|
+
makeFormData({
|
|
632
|
+
direct_groups: [],
|
|
633
|
+
nominated_groups: [],
|
|
634
|
+
purposes: [{ purpose: basePurpose('purp-flat', 'Marketing', []) }],
|
|
635
|
+
}),
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
render(<Form onCreateRequest={vi.fn()} />);
|
|
639
|
+
await waitFor(() => expect(getFormData).toHaveBeenCalled());
|
|
640
|
+
|
|
641
|
+
pickRequestType(RequestTypeEnum.ACCESS);
|
|
642
|
+
|
|
643
|
+
await waitFor(() => {
|
|
644
|
+
expect(screen.getByText('No products available')).toBeInTheDocument();
|
|
645
|
+
});
|
|
646
|
+
expect(screen.queryByText('Your data')).not.toBeInTheDocument();
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('submits the legacy flat fallback as an org-level case without a product anchor', async () => {
|
|
650
|
+
// The synthetic group has no server-side product, so the case must go up
|
|
651
|
+
// in the pre-grouping org-level shape rather than carrying a made-up
|
|
652
|
+
// `product_uuid` the backend cannot resolve.
|
|
653
|
+
setupSession();
|
|
654
|
+
fetchUserConsents.mockResolvedValue(null);
|
|
655
|
+
getFormData.mockResolvedValue(
|
|
656
|
+
makeFormData({
|
|
657
|
+
direct_groups: [],
|
|
658
|
+
nominated_groups: [],
|
|
659
|
+
purposes: [{ purpose: basePurpose('purp-flat', 'Marketing') }],
|
|
660
|
+
}),
|
|
661
|
+
);
|
|
662
|
+
createCaseRequest.mockResolvedValueOnce({
|
|
663
|
+
case_id: 'CASE-FLAT',
|
|
664
|
+
uuid: 'uuid-flat',
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
render(<Form onCreateRequest={vi.fn()} />);
|
|
668
|
+
await waitFor(() => expect(getFormData).toHaveBeenCalled());
|
|
669
|
+
|
|
670
|
+
pickRequestType(RequestTypeEnum.ACCESS);
|
|
671
|
+
|
|
672
|
+
await waitFor(() => {
|
|
673
|
+
expect(screen.getByText('Marketing')).toBeInTheDocument();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const marketingCheckbox = screen
|
|
677
|
+
.getByText('Marketing')
|
|
678
|
+
.closest('label')
|
|
679
|
+
?.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
|
|
680
|
+
expect(marketingCheckbox).toBeTruthy();
|
|
681
|
+
fireEvent.click(marketingCheckbox!);
|
|
682
|
+
|
|
683
|
+
const confirmCheckbox = (
|
|
684
|
+
screen.getAllByRole('checkbox') as HTMLInputElement[]
|
|
685
|
+
).find((c) => c.closest('label')?.textContent?.includes('I confirm'));
|
|
686
|
+
expect(confirmCheckbox).toBeTruthy();
|
|
687
|
+
fireEvent.click(confirmCheckbox!);
|
|
688
|
+
|
|
689
|
+
const submitButton = screen.getByRole('button', { name: /^submit$/i });
|
|
690
|
+
expect(submitButton).not.toBeDisabled();
|
|
691
|
+
await act(async () => {
|
|
692
|
+
fireEvent.click(submitButton);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
await waitFor(() => expect(createCaseRequest).toHaveBeenCalledTimes(1));
|
|
696
|
+
|
|
697
|
+
const payload = createCaseRequest.mock.calls[0][2];
|
|
698
|
+
expect('product_uuid' in payload).toBe(false);
|
|
699
|
+
expect('data_principal_uuid' in payload).toBe(false);
|
|
700
|
+
expect(payload.request_details.purposes).toEqual([
|
|
701
|
+
{ purpose_uuid: 'purp-flat', data_element_uuids: ['purp-flat-de1'] },
|
|
702
|
+
]);
|
|
703
|
+
});
|
|
704
|
+
|
|
522
705
|
it('renders the product step for DSAR types when direct_groups is present', async () => {
|
|
523
706
|
setupSession();
|
|
524
707
|
const direct = makeGroup({
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// react-i18next is an optional peer dep that is not installed in this
|
|
5
|
+
// package's node_modules. Stub it with a minimal `useTranslation` that
|
|
6
|
+
// resolves the keys used by the components under test, honouring the inline
|
|
7
|
+
// English fallback passed as the 2nd argument (mirrors the real i18next
|
|
8
|
+
// behaviour when a key is missing).
|
|
9
|
+
vi.mock('react-i18next', () => {
|
|
10
|
+
const en: Record<string, string> = {
|
|
11
|
+
modifyConsent: 'Modify Consent',
|
|
12
|
+
closeModal: 'Close',
|
|
13
|
+
cancel: 'Cancel',
|
|
14
|
+
revokeConsent: 'Revoke Consent',
|
|
15
|
+
revokeConsentWarning: 'If you revoke this consent, you may lose access.',
|
|
16
|
+
regrantConsent: 'Regrant Consent',
|
|
17
|
+
regrantConsentWarning: 'Regranting will restore this consent.',
|
|
18
|
+
aboutToModifyConsent: 'You are about to modify this consent.',
|
|
19
|
+
aboutToRegrantConsent: 'You are about to regrant this consent.',
|
|
20
|
+
noDataElementsAvailable: 'No data elements available',
|
|
21
|
+
noActionsAvailable: 'No actions available for this record.',
|
|
22
|
+
};
|
|
23
|
+
const interpolate = (template: string, vars?: Record<string, unknown>) =>
|
|
24
|
+
template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => {
|
|
25
|
+
const value = vars?.[key];
|
|
26
|
+
return value === undefined || value === null ? '' : String(value);
|
|
27
|
+
});
|
|
28
|
+
// Supports the `t(key, fallbackString)` and `t(key, opts)` call shapes used
|
|
29
|
+
// across the components. When the key is unknown, fall back to the inline
|
|
30
|
+
// English string if one was supplied, otherwise echo the key.
|
|
31
|
+
const t = (key: string, second?: unknown, opts?: Record<string, unknown>) => {
|
|
32
|
+
const fallback = typeof second === 'string' ? second : undefined;
|
|
33
|
+
const interpolationOpts =
|
|
34
|
+
typeof second === 'object' && second !== null
|
|
35
|
+
? (second as Record<string, unknown>)
|
|
36
|
+
: opts;
|
|
37
|
+
const template = en[key] ?? fallback ?? key;
|
|
38
|
+
return interpolate(template, interpolationOpts);
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
useTranslation: () => ({ t, i18n: { language: 'en' } }),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Icon peer deps — stub the icons used by the modal.
|
|
46
|
+
vi.mock('lucide-react', () => ({
|
|
47
|
+
ChevronDownIcon: () => null,
|
|
48
|
+
X: () => null,
|
|
49
|
+
Smartphone: () => null,
|
|
50
|
+
LaptopMinimal: () => null,
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
vi.mock('react-icons/pi', () => ({
|
|
54
|
+
PiWarningCircleBold: () => null,
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
import { ModifyConsentModal } from '../src/RedactoPrivacyCenter/components/ModifyConsentModal';
|
|
58
|
+
import { NavigationProvider } from '../src/RedactoPrivacyCenter/context/NavigationContext';
|
|
59
|
+
import { ConsentStatusEnum } from '../src/RedactoPrivacyCenter/lib/types';
|
|
60
|
+
import type { ConsentModalRecord } from '../src/RedactoPrivacyCenter/lib/types';
|
|
61
|
+
|
|
62
|
+
const DEFAULT_WARNING = 'If you revoke this consent, you may lose access.';
|
|
63
|
+
const CUSTOM_WARNING =
|
|
64
|
+
'Revoking marketing consent will stop all promotional emails immediately.';
|
|
65
|
+
|
|
66
|
+
const makeRecord = (
|
|
67
|
+
overrides: Partial<ConsentModalRecord> = {},
|
|
68
|
+
): ConsentModalRecord => ({
|
|
69
|
+
purpose_uuid: 'purpose-1',
|
|
70
|
+
name: 'Marketing analytics',
|
|
71
|
+
description: 'Improve product recommendations',
|
|
72
|
+
status: ConsentStatusEnum.ACTIVE,
|
|
73
|
+
given_date: '2026-01-01T00:00:00Z',
|
|
74
|
+
valid_till: null,
|
|
75
|
+
method: 'WEB',
|
|
76
|
+
data_elements: [],
|
|
77
|
+
link_reason: null,
|
|
78
|
+
product_uuid: 'prod-1',
|
|
79
|
+
product_name: 'Acme Loyalty',
|
|
80
|
+
product_description: null,
|
|
81
|
+
nominator: null,
|
|
82
|
+
...overrides,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const renderModal = (record: ConsentModalRecord) =>
|
|
86
|
+
render(
|
|
87
|
+
<NavigationProvider>
|
|
88
|
+
<ModifyConsentModal
|
|
89
|
+
isOpen
|
|
90
|
+
onClose={vi.fn()}
|
|
91
|
+
selectedRecord={record}
|
|
92
|
+
isLoading={false}
|
|
93
|
+
onManageConsent={vi.fn()}
|
|
94
|
+
/>
|
|
95
|
+
</NavigationProvider>,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
describe('per-purpose revoke warning message', () => {
|
|
99
|
+
it('shows the custom warning when revoke_warning_message is set on a revoke record', () => {
|
|
100
|
+
renderModal(makeRecord({ revoke_warning_message: CUSTOM_WARNING }));
|
|
101
|
+
|
|
102
|
+
expect(screen.getByText(CUSTOM_WARNING)).toBeInTheDocument();
|
|
103
|
+
// The localized default must be suppressed when a custom message exists.
|
|
104
|
+
expect(screen.queryByText(DEFAULT_WARNING)).not.toBeInTheDocument();
|
|
105
|
+
// Button label is untouched — still the default revoke copy.
|
|
106
|
+
expect(
|
|
107
|
+
screen.getByRole('button', { name: 'Revoke Consent' }),
|
|
108
|
+
).toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('shows the localized default warning when revoke_warning_message is null', () => {
|
|
112
|
+
renderModal(makeRecord({ revoke_warning_message: null }));
|
|
113
|
+
|
|
114
|
+
expect(screen.getByText(DEFAULT_WARNING)).toBeInTheDocument();
|
|
115
|
+
expect(
|
|
116
|
+
screen.getByRole('button', { name: 'Revoke Consent' }),
|
|
117
|
+
).toBeInTheDocument();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('shows the localized default warning when revoke_warning_message is an empty string', () => {
|
|
121
|
+
renderModal(makeRecord({ revoke_warning_message: '' }));
|
|
122
|
+
|
|
123
|
+
expect(screen.getByText(DEFAULT_WARNING)).toBeInTheDocument();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('shows the localized default warning when revoke_warning_message is absent (older backends)', () => {
|
|
127
|
+
renderModal(makeRecord()); // no `revoke_warning_message` field
|
|
128
|
+
|
|
129
|
+
expect(screen.getByText(DEFAULT_WARNING)).toBeInTheDocument();
|
|
130
|
+
expect(
|
|
131
|
+
screen.getByRole('button', { name: 'Revoke Consent' }),
|
|
132
|
+
).toBeInTheDocument();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('does not apply the custom revoke warning to the regrant branch', () => {
|
|
136
|
+
// A withdrawn record uses the regrant action; the per-purpose revoke
|
|
137
|
+
// message must never leak into the regrant warning.
|
|
138
|
+
renderModal(
|
|
139
|
+
makeRecord({
|
|
140
|
+
status: ConsentStatusEnum.WITHDRAWN,
|
|
141
|
+
revoke_warning_message: CUSTOM_WARNING,
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(screen.getByText('Regranting will restore this consent.')).toBeInTheDocument();
|
|
146
|
+
expect(screen.queryByText(CUSTOM_WARNING)).not.toBeInTheDocument();
|
|
147
|
+
expect(
|
|
148
|
+
screen.getByRole('button', { name: 'Regrant Consent' }),
|
|
149
|
+
).toBeInTheDocument();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createConsentClient } from '../src/RedactoPrivacyCenter/api/client';
|
|
4
|
+
import type { BaseRequest } from '../src/RedactoPrivacyCenter/api/client';
|
|
5
|
+
|
|
6
|
+
// URL routing contract for the five consent read/write methods that moved to
|
|
7
|
+
// the Go ledger in PR #438. With a ledgerBaseUrl they must hit the ledger's
|
|
8
|
+
// /public/... paths; without one they must fall back to the legacy Python
|
|
9
|
+
// /dsar/privacy-center paths (prod has no ledger — see PR #438/#460 regression
|
|
10
|
+
// where the silent baseUrl fallback 404'd the Privacy Center).
|
|
11
|
+
|
|
12
|
+
const BASE_URL = 'https://consent.test';
|
|
13
|
+
const LEDGER_URL = 'https://ledger.test/consent-ledger';
|
|
14
|
+
const LEGACY_PREFIX =
|
|
15
|
+
'https://consent.test/organisations/org-1/workspaces/ws-1/dsar/privacy-center';
|
|
16
|
+
const LEDGER_PREFIX =
|
|
17
|
+
'https://ledger.test/consent-ledger/public/organisations/org-1/workspaces/ws-1';
|
|
18
|
+
|
|
19
|
+
const baseRequest: BaseRequest = {
|
|
20
|
+
organisationUuid: 'org-1',
|
|
21
|
+
workspaceUuid: 'ws-1',
|
|
22
|
+
authToken: 'jwt-token',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const jsonResponse = () =>
|
|
26
|
+
new Response(JSON.stringify({}), {
|
|
27
|
+
status: 200,
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const pdfResponse = () =>
|
|
32
|
+
new Response(new Blob(['%PDF'], { type: 'application/pdf' }), {
|
|
33
|
+
status: 200,
|
|
34
|
+
headers: { 'Content-Type': 'application/pdf' },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const mockFetch = vi.fn();
|
|
38
|
+
|
|
39
|
+
const fetchedUrl = (): string => String(mockFetch.mock.calls[0]?.[0]);
|
|
40
|
+
|
|
41
|
+
const fetchedBody = (): Record<string, unknown> =>
|
|
42
|
+
JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body)) as Record<
|
|
43
|
+
string,
|
|
44
|
+
unknown
|
|
45
|
+
>;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
mockFetch.mockReset();
|
|
49
|
+
mockFetch.mockImplementation(() => Promise.resolve(jsonResponse()));
|
|
50
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
vi.unstubAllGlobals();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('createConsentClient — legacy mode (no ledgerBaseUrl)', () => {
|
|
58
|
+
const client = createConsentClient(BASE_URL);
|
|
59
|
+
|
|
60
|
+
it('routes getUserConsents to the legacy dsar/privacy-center path', async () => {
|
|
61
|
+
await client.getUserConsents(baseRequest, { offset: 0, limit: 10 });
|
|
62
|
+
expect(fetchedUrl()).toBe(
|
|
63
|
+
`${LEGACY_PREFIX}/user-consents?offset=0&limit=10`,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('routes getGroupPurposes to the legacy dsar/privacy-center path', async () => {
|
|
68
|
+
await client.getGroupPurposes(baseRequest, 'prod-1', { limit: 5 });
|
|
69
|
+
expect(fetchedUrl()).toBe(
|
|
70
|
+
`${LEGACY_PREFIX}/user-consents/prod-1/purposes?limit=5`,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('routes manageConsent to the legacy manage-consent endpoint with the legacy body and no noticeUuid requirement', async () => {
|
|
75
|
+
await client.manageConsent(
|
|
76
|
+
baseRequest,
|
|
77
|
+
'purp-1',
|
|
78
|
+
'jane@example.com',
|
|
79
|
+
'revoke',
|
|
80
|
+
undefined,
|
|
81
|
+
undefined,
|
|
82
|
+
'nominator@example.com',
|
|
83
|
+
['de-1'],
|
|
84
|
+
'prod-1',
|
|
85
|
+
);
|
|
86
|
+
expect(fetchedUrl()).toBe(`${LEGACY_PREFIX}/manage-consent?status=revoke`);
|
|
87
|
+
expect(fetchedBody()).toEqual({
|
|
88
|
+
purpose_uuid: 'purp-1',
|
|
89
|
+
contact: 'jane@example.com',
|
|
90
|
+
nominator_contact: 'nominator@example.com',
|
|
91
|
+
data_element_uuids: ['de-1'],
|
|
92
|
+
product_uuid: 'prod-1',
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('records the selected language in the legacy manage-consent body', async () => {
|
|
97
|
+
await client.manageConsent(
|
|
98
|
+
baseRequest,
|
|
99
|
+
'purp-1',
|
|
100
|
+
'jane@example.com',
|
|
101
|
+
'revoke',
|
|
102
|
+
undefined,
|
|
103
|
+
undefined,
|
|
104
|
+
undefined,
|
|
105
|
+
undefined,
|
|
106
|
+
undefined,
|
|
107
|
+
undefined,
|
|
108
|
+
undefined,
|
|
109
|
+
'doi',
|
|
110
|
+
);
|
|
111
|
+
expect(fetchedBody()).toMatchObject({ language: 'doi' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('routes getReceipts to the legacy dsar/privacy-center path', async () => {
|
|
115
|
+
await client.getReceipts(baseRequest, { skip: 0, limit: 10 });
|
|
116
|
+
expect(fetchedUrl()).toBe(`${LEGACY_PREFIX}/receipts?skip=0&limit=10`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('routes getReceiptPdf to the legacy /pdf path', async () => {
|
|
120
|
+
mockFetch.mockImplementation(() => Promise.resolve(pdfResponse()));
|
|
121
|
+
await client.getReceiptPdf(baseRequest, 'r-1');
|
|
122
|
+
expect(fetchedUrl()).toBe(`${LEGACY_PREFIX}/receipts/r-1/pdf`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('appends the language query param to getActivities when provided', async () => {
|
|
126
|
+
await client.getActivities(baseRequest, 0, 15, 'doi');
|
|
127
|
+
expect(fetchedUrl()).toContain('language=doi');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('omits the language query param from getActivities for the default language', async () => {
|
|
131
|
+
await client.getActivities(baseRequest, 0, 15);
|
|
132
|
+
expect(fetchedUrl()).not.toContain('language=');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('createConsentClient — ledger mode (ledgerBaseUrl set)', () => {
|
|
137
|
+
// Trailing slash must be trimmed from the configured ledger base.
|
|
138
|
+
const client = createConsentClient(BASE_URL, `${LEDGER_URL}/`);
|
|
139
|
+
|
|
140
|
+
it('routes getUserConsents to the ledger public path', async () => {
|
|
141
|
+
await client.getUserConsents(baseRequest, { offset: 0, limit: 10 });
|
|
142
|
+
expect(fetchedUrl()).toBe(
|
|
143
|
+
`${LEDGER_PREFIX}/user-consents?offset=0&limit=10`,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('routes getGroupPurposes to the ledger public path', async () => {
|
|
148
|
+
await client.getGroupPurposes(baseRequest, 'prod-1', { limit: 5 });
|
|
149
|
+
expect(fetchedUrl()).toBe(
|
|
150
|
+
`${LEDGER_PREFIX}/user-consents/prod-1/purposes?limit=5`,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('routes manageConsent to the ledger submit-consent with a partial payload', async () => {
|
|
155
|
+
await client.manageConsent(
|
|
156
|
+
baseRequest,
|
|
157
|
+
'purp-1',
|
|
158
|
+
'jane@example.com',
|
|
159
|
+
'revoke',
|
|
160
|
+
undefined,
|
|
161
|
+
undefined,
|
|
162
|
+
undefined,
|
|
163
|
+
undefined,
|
|
164
|
+
undefined,
|
|
165
|
+
'notice-1',
|
|
166
|
+
[{ uuid: 'de-1', selected: true }],
|
|
167
|
+
);
|
|
168
|
+
expect(fetchedUrl()).toBe(`${LEDGER_PREFIX}/submit-consent`);
|
|
169
|
+
expect(fetchedBody()).toMatchObject({
|
|
170
|
+
notice_uuid: 'notice-1',
|
|
171
|
+
partial: true,
|
|
172
|
+
purposes: [
|
|
173
|
+
{
|
|
174
|
+
purpose_uuid: 'purp-1',
|
|
175
|
+
selected: false,
|
|
176
|
+
data_elements: [{ uuid: 'de-1', selected: false }],
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('records the selected language in the ledger partial submit body', async () => {
|
|
183
|
+
await client.manageConsent(
|
|
184
|
+
baseRequest,
|
|
185
|
+
'purp-1',
|
|
186
|
+
'jane@example.com',
|
|
187
|
+
'revoke',
|
|
188
|
+
undefined,
|
|
189
|
+
undefined,
|
|
190
|
+
undefined,
|
|
191
|
+
undefined,
|
|
192
|
+
undefined,
|
|
193
|
+
'notice-1',
|
|
194
|
+
[{ uuid: 'de-1', selected: true }],
|
|
195
|
+
'doi',
|
|
196
|
+
);
|
|
197
|
+
expect(fetchedBody()).toMatchObject({ language: 'doi' });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('throws from manageConsent when noticeUuid is missing', async () => {
|
|
201
|
+
await expect(
|
|
202
|
+
client.manageConsent(baseRequest, 'purp-1', 'jane@example.com', 'revoke'),
|
|
203
|
+
).rejects.toThrow('notice_uuid is required');
|
|
204
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('routes getReceipts to the ledger public path', async () => {
|
|
208
|
+
await client.getReceipts(baseRequest, { skip: 0, limit: 10 });
|
|
209
|
+
expect(fetchedUrl()).toBe(`${LEDGER_PREFIX}/receipts?skip=0&limit=10`);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('routes getReceiptPdf to the ledger /download path', async () => {
|
|
213
|
+
mockFetch.mockImplementation(() => Promise.resolve(pdfResponse()));
|
|
214
|
+
await client.getReceiptPdf(baseRequest, 'r-1');
|
|
215
|
+
expect(fetchedUrl()).toBe(`${LEDGER_PREFIX}/receipts/r-1/download`);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { manageConsentServer } from '../src/RedactoPrivacyCenter/api/actions';
|
|
4
|
+
import type { createConsentClient } from '../src/RedactoPrivacyCenter/api/client';
|
|
5
|
+
|
|
6
|
+
// manageConsentServer must honor the legacy Python response's `success` flag
|
|
7
|
+
// (a 200 with { success: false } is a failure) while still treating the Go
|
|
8
|
+
// ledger's ConsentEventResponse (no `success` key) as resolved-on-2xx.
|
|
9
|
+
|
|
10
|
+
type ConsentClient = ReturnType<typeof createConsentClient>;
|
|
11
|
+
|
|
12
|
+
const makeParams = (result: unknown) => ({
|
|
13
|
+
organizationId: 'org-1',
|
|
14
|
+
workspaceId: 'ws-1',
|
|
15
|
+
purposeId: 'purp-1',
|
|
16
|
+
contact: 'jane@example.com',
|
|
17
|
+
action: 'revoke' as const,
|
|
18
|
+
authToken: 'jwt-token',
|
|
19
|
+
consentClient: {
|
|
20
|
+
manageConsent: vi.fn().mockResolvedValue(result),
|
|
21
|
+
} as unknown as ConsentClient,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('manageConsentServer success detection', () => {
|
|
25
|
+
it('returns false for a legacy { success: false } response', async () => {
|
|
26
|
+
await expect(manageConsentServer(makeParams({ success: false }))).resolves.toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns true for a legacy { success: true } response', async () => {
|
|
30
|
+
await expect(manageConsentServer(makeParams({ success: true }))).resolves.toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns true for a ledger ConsentEventResponse (no success flag)', async () => {
|
|
34
|
+
await expect(
|
|
35
|
+
manageConsentServer(makeParams({ consent_event_uuid: 'evt-1' })),
|
|
36
|
+
).resolves.toBe(true);
|
|
37
|
+
});
|
|
38
|
+
});
|