Files
consentos/apps/admin-ui/src/test/SiteConfigTab.test.tsx
James Cottrill 8d15ec4398 Per-site configurable cookie categories (#3)
* feat: per-site configurable cookie categories

Operators can now choose which cookie categories the banner displays
for a given site — useful for sites that genuinely don't use
e.g. marketing cookies and shouldn't be forced to show the toggle.

**Backend**

* New ``enabled_categories`` JSONB column on ``site_configs``,
  ``site_group_configs``, and ``org_configs`` (migration 0003).
  NULL at a level means "inherit"; an explicit list overrides.
* ``config_resolver`` merges ``enabled_categories`` through the
  existing cascade (system → org → group → site) and normalises
  the result via ``_normalise_enabled_categories``:
  - Unknown slugs stripped.
  - ``necessary`` is forced in regardless of the operator's input
    — it's never optional.
  - Empty / invalid values fall back to the full five-category
    default so a cleared field doesn't silently hide the banner.
  - Output is returned in canonical display order so insertion
    order from the cascade doesn't leak into the UI.
* ``build_public_config`` surfaces ``enabled_categories`` to the
  banner-facing public config endpoint.
* Schemas for site/group/org config create + update + response all
  include the new field.

**Banner**

* ``apps/banner/src/banner.ts`` replaces the hard-coded
  ``ALL_CATEGORIES`` / ``NON_ESSENTIAL`` constants with a runtime
  ``resolveEnabledCategories(config)`` helper. ``renderCategories``
  takes the enabled list and only renders toggles for those
  categories; ``nonEssentialFor(enabled)`` derives the user-toggleable
  subset. Falls back to all five when the field is missing in the
  config payload so older banner bundles against newer APIs (and
  vice versa) don't break.
* ``SiteConfig`` type in ``apps/banner/src/types.ts`` has
  ``enabled_categories?: CategorySlug[]`` to match.

**Admin UI**

* New ``SiteCategoriesTab`` component — five checkboxes, ``necessary``
  locked on, with "Reset to inherited" to clear the site override.
  Wired in as a new core tab on ``SiteDetailPage`` between
  Configuration and Cookies.
* ``SiteConfig`` type in ``types/api.ts`` declares ``enabled_categories``
  and a new ``ALL_COOKIE_CATEGORIES`` constant exposing label/description
  metadata shared between the tab component and any future display of
  the list.

**Semantics of a disabled category**

When the operator unticks e.g. ``marketing`` for a site:

* The toggle is not rendered in the banner.
* A visitor can never grant consent for ``marketing``.
* Any cookie or script that classifies into ``marketing`` stays
  blocked permanently by the auto-blocker.

That's the correct behaviour for sites that genuinely don't use a
category: declare it, hide it from the visitor, have the blocker
enforce it.

**Tests**

* ``test_config_resolver.py`` — 13 new cases covering the full
  cascade, ``necessary`` forcing, unknown-slug stripping, empty /
  non-list values, canonical display order, and the public-config
  surface. 37 passed total.
* ``test_SiteCategoriesTab.test.tsx`` — renders all five, locks
  ``necessary``, pre-fills from an override, saves the explicit
  list, and resets to inherited by sending NULL. 6 cases.
* Full API suite (610) and admin-ui suite (139) both green;
  banner bundle builds cleanly with 363 tests passing.

* style: ruff format config_resolver.py
2026-04-14 14:05:31 +01:00

266 lines
8.1 KiB
TypeScript

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import SiteConfigTab from '../components/SiteConfigTab';
import type { SiteConfig } from '../types/api';
vi.mock('../api/sites', () => ({
updateSiteConfig: vi.fn(() => Promise.resolve({})),
}));
function createQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function renderWithProviders(ui: React.ReactElement) {
const queryClient = createQueryClient();
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
}
const BASE_CONFIG: SiteConfig = {
id: 'cfg-1',
site_id: 'site-1',
blocking_mode: 'opt_in',
regional_modes: null,
tcf_enabled: false,
gpp_enabled: true,
gpp_supported_apis: ['usnat'],
gpc_enabled: true,
gpc_jurisdictions: ['US-CA', 'US-CO', 'US-CT', 'US-TX', 'US-MT'],
gpc_global_honour: false,
gcm_enabled: true,
gcm_default: null,
shopify_privacy_enabled: false,
banner_config: null,
privacy_policy_url: null,
terms_url: null,
consent_expiry_days: 365,
scan_enabled: true,
scan_frequency_hours: 168,
scan_max_pages: 50,
enabled_categories: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
};
describe('SiteConfigTab', () => {
it('renders consent settings section', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(screen.getByText('Consent settings')).toBeInTheDocument();
expect(screen.getByText('Blocking mode')).toBeInTheDocument();
expect(screen.getByText('Consent expiry (days)')).toBeInTheDocument();
expect(screen.getByText('Privacy policy URL')).toBeInTheDocument();
});
it('renders standards section with TCF and GCM toggles', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(screen.getByText('Standards & integrations')).toBeInTheDocument();
expect(screen.getByText('IAB TCF v2.2')).toBeInTheDocument();
expect(screen.getByText('Google Consent Mode v2')).toBeInTheDocument();
});
it('renders GPP section with enable toggle', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(
screen.getByText('IAB Global Privacy Platform (GPP)'),
).toBeInTheDocument();
expect(screen.getByText('Enable GPP')).toBeInTheDocument();
});
it('shows GPP supported sections when GPP is enabled', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(screen.getByText('Supported sections')).toBeInTheDocument();
expect(
screen.getByText('US National Privacy (Section 7)'),
).toBeInTheDocument();
expect(
screen.getByText('US California — CCPA/CPRA (Section 8)'),
).toBeInTheDocument();
});
it('hides GPP supported sections when GPP is disabled', () => {
const config = { ...BASE_CONFIG, gpp_enabled: false };
renderWithProviders(
<SiteConfigTab siteId="site-1" config={config} />,
);
expect(screen.queryByText('Supported sections')).not.toBeInTheDocument();
});
it('renders GPC section with detect toggle', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(
screen.getByText('Global Privacy Control (GPC)'),
).toBeInTheDocument();
expect(screen.getByText('Detect GPC signal')).toBeInTheDocument();
});
it('shows GPC jurisdiction list when GPC is enabled', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(screen.getByText('California (CCPA/CPRA)')).toBeInTheDocument();
expect(screen.getByText('Colorado (CPA)')).toBeInTheDocument();
expect(screen.getByText('Connecticut (CTDPA)')).toBeInTheDocument();
expect(screen.getByText('Texas (TDPSA)')).toBeInTheDocument();
expect(screen.getByText('Montana (MTCDPA)')).toBeInTheDocument();
});
it('hides GPC jurisdictions when GPC is disabled', () => {
const config = { ...BASE_CONFIG, gpc_enabled: false };
renderWithProviders(
<SiteConfigTab siteId="site-1" config={config} />,
);
expect(
screen.queryByText('California (CCPA/CPRA)'),
).not.toBeInTheDocument();
expect(screen.queryByText('Honour globally')).not.toBeInTheDocument();
});
it('shows honour globally toggle when GPC is enabled', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(screen.getByText('Honour globally')).toBeInTheDocument();
});
it('hides jurisdiction list when global honour is enabled', () => {
const config = { ...BASE_CONFIG, gpc_global_honour: true };
renderWithProviders(
<SiteConfigTab siteId="site-1" config={config} />,
);
expect(screen.getByText('Honour globally')).toBeInTheDocument();
expect(
screen.queryByText('Jurisdictions where GPC is legally required'),
).not.toBeInTheDocument();
});
it('toggles GPP section checkbox', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
// usnat should be checked by default
const usnatLabel = screen
.getByText('US National Privacy (Section 7)')
.closest('label')!;
const usnatCheckbox = usnatLabel.querySelector('input') as HTMLInputElement;
expect(usnatCheckbox.checked).toBe(true);
// usca should be unchecked
const uscaLabel = screen
.getByText('US California — CCPA/CPRA (Section 8)')
.closest('label')!;
const uscaCheckbox = uscaLabel.querySelector('input') as HTMLInputElement;
expect(uscaCheckbox.checked).toBe(false);
// Toggle usca on
fireEvent.click(uscaCheckbox);
expect(uscaCheckbox.checked).toBe(true);
});
it('submits GPP/GPC configuration', async () => {
const sitesApi = await import('../api/sites');
const spy = vi.mocked(sitesApi.updateSiteConfig);
spy.mockClear();
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
const saveBtn = screen.getByText('Save configuration');
fireEvent.click(saveBtn);
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(
'site-1',
expect.objectContaining({
gpp_enabled: true,
gpp_supported_apis: ['usnat'],
gpc_enabled: true,
gpc_jurisdictions: ['US-CA', 'US-CO', 'US-CT', 'US-TX', 'US-MT'],
gpc_global_honour: false,
}),
);
});
});
it('nulls gpp_supported_apis when GPP is disabled on submit', async () => {
const sitesApi = await import('../api/sites');
const spy = vi.mocked(sitesApi.updateSiteConfig);
spy.mockClear();
const config = { ...BASE_CONFIG, gpp_enabled: false };
renderWithProviders(
<SiteConfigTab siteId="site-1" config={config} />,
);
const saveBtn = screen.getByText('Save configuration');
fireEvent.click(saveBtn);
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(
'site-1',
expect.objectContaining({
gpp_enabled: false,
gpp_supported_apis: null,
}),
);
});
});
it('nulls gpc_jurisdictions when GPC is disabled on submit', async () => {
const sitesApi = await import('../api/sites');
const spy = vi.mocked(sitesApi.updateSiteConfig);
spy.mockClear();
const config = { ...BASE_CONFIG, gpc_enabled: false };
renderWithProviders(
<SiteConfigTab siteId="site-1" config={config} />,
);
const saveBtn = screen.getByText('Save configuration');
fireEvent.click(saveBtn);
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(
'site-1',
expect.objectContaining({
gpc_enabled: false,
gpc_jurisdictions: null,
}),
);
});
});
it('renders save button and shows success message', async () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(screen.getByText('Save configuration')).toBeInTheDocument();
});
});