File size: 6,110 Bytes
0f4d9ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { I18nProvider } from '../i18n'
import { ConsentControls } from './ConsentControls'
import * as ai from '../api/ai'
import { ApiRequestError } from '../api/client'

vi.mock('../api/ai')

function mkStatus(over: Partial<Record<ai.ConsentPurpose, Partial<ai.PurposeMeta>>> = {}): ai.ProfileStatus {
  const opt = (status: ai.ConsentStatus, version: number): ai.PurposeMeta =>
    ({ purpose_kind: 'optional_consent', withdrawable: true, status, version, updated_at: 1 })
  const req: ai.PurposeMeta = { purpose_kind: 'required_processing', withdrawable: false, state: 'active', version: 1, updated_at: 1 }
  const purposes = {
    service_essential: { ...req, ...(over.service_essential ?? {}) },
    optional_personalization: { ...opt('not_granted', 0), ...(over.optional_personalization ?? {}) },
    optional_federation: { ...opt('not_granted', 0), ...(over.optional_federation ?? {}) },
  } as ai.ProfileStatus['purposes']
  return {
    subject: 'alice', policy_version: 'c4-policy-v1', record_version: 3, profile_generation: 1,
    purposes,
    personalization_enabled: purposes.optional_personalization.status === 'granted',
    federation_enrolled: purposes.optional_federation.status === 'granted',
    personalization_without_shared_learning:
      purposes.optional_personalization.status === 'granted' && purposes.optional_federation.status !== 'granted',
    affects_payment: false, authoritative: false,
    storage_consistency: { backend: 'memory', durable_multi_replica: false, note: 'ephemeral' },
    label: 'Advisory only',
  }
}

const renderCtl = () => render(<I18nProvider initial="en"><ConsentControls /></I18nProvider>)

describe('ConsentControls', () => {
  beforeEach(() => { vi.spyOn(ai, 'profileStatus').mockResolvedValue(mkStatus()) })
  afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })

  it('shows required processing as informational (no toggle, not "granted consent")', async () => {
    renderCtl()
    await screen.findByTestId('consent-service_essential')
    expect(screen.getByTestId('required-state')).toHaveTextContent('Active')
    expect(screen.getByText('Required')).toBeInTheDocument()
    expect(screen.queryByTestId('consent-toggle-service_essential')).toBeNull()
    // required processing must not be labelled as granted consent
    expect(screen.getByTestId('consent-service_essential').textContent).not.toMatch(/granted/i)
  })

  it('optional purposes default to not-granted (opt-in)', async () => {
    renderCtl()
    expect(await screen.findByTestId('consent-state-optional_personalization')).toHaveTextContent('Not granted')
    expect(screen.getByTestId('consent-state-optional_federation')).toHaveTextContent('Not granted')
  })

  it('granting personalization sends expected_version and reflects the new state', async () => {
    vi.spyOn(ai, 'setConsent').mockResolvedValue(mkStatus({ optional_personalization: { status: 'granted', version: 1 } }))
    renderCtl()
    fireEvent.click(await screen.findByTestId('consent-toggle-optional_personalization'))
    await waitFor(() => expect(ai.setConsent).toHaveBeenCalledWith('optional_personalization', true, 3))
    expect(await screen.findByTestId('consent-state-optional_personalization')).toHaveTextContent('Granted')
    expect(screen.getByTestId('pwsl')).toBeInTheDocument()   // personalization without shared learning
  })

  it('personalization and federation are independent toggles', async () => {
    vi.spyOn(ai, 'setConsent').mockResolvedValue(mkStatus({ optional_federation: { status: 'granted', version: 1 } }))
    renderCtl()
    fireEvent.click(await screen.findByTestId('consent-toggle-optional_federation'))
    await waitFor(() => expect(ai.setConsent).toHaveBeenCalledWith('optional_federation', true, 3))
    // federation granted, personalization still not granted
    expect(screen.getByTestId('consent-state-optional_personalization')).toHaveTextContent('Not granted')
  })

  it('on a 409 conflict, refetches and shows a conflict notice', async () => {
    vi.spyOn(ai, 'setConsent').mockRejectedValue(new ApiRequestError({ status: 409, message: 'version_conflict' }))
    const refetch = vi.spyOn(ai, 'profileStatus')
    renderCtl()
    fireEvent.click(await screen.findByTestId('consent-toggle-optional_personalization'))
    await waitFor(() => expect(screen.getByText(/changed elsewhere/i)).toBeInTheDocument())
    expect(refetch.mock.calls.length).toBeGreaterThanOrEqual(2)   // initial + refetch after conflict
  })

  it('does not delete when the confirm dialog is cancelled', async () => {
    const confirmMock = vi.fn(() => false)
    vi.stubGlobal('confirm', confirmMock)
    const del = vi.spyOn(ai, 'deleteProfile')
    renderCtl()
    fireEvent.click(await screen.findByTestId('consent-delete'))
    await waitFor(() => expect(confirmMock).toHaveBeenCalled())
    expect(del).not.toHaveBeenCalled()
  })

  it('deletes eligible data and shows deleted/retained categories', async () => {
    vi.stubGlobal('confirm', vi.fn(() => true))
    vi.spyOn(ai, 'deleteProfile').mockResolvedValue({
      subject: 'alice', profile: 'deleted', deleted_now: true,
      deleted_categories: ['derived_behavioural_features', 'optional_personalization_state'],
      retained_categories: ['required_security_processing_records', 'minimal_deletion_tombstone'],
      retention_reason: 'required', retired_generation: 1, audit_reference: 'abc123',
      erasure_type: 'eligible_scope_deletion', affects_payment: false, note: 'eligible-scope',
    })
    renderCtl()
    fireEvent.click(await screen.findByTestId('consent-delete'))
    await waitFor(() => expect(ai.deleteProfile).toHaveBeenCalled())
    const receipt = await screen.findByTestId('deletion-receipt')
    expect(receipt).toHaveTextContent(/derived_behavioural_features/)
    expect(receipt).toHaveTextContent(/required_security_processing_records/)
    expect(receipt).toHaveTextContent(/not perfect erasure or machine unlearning/i)
  })
})