fix(privacy): separate analytics consent from email consent (#56)
The previous commit widened the marketing consent sentence to cover the Brevo tracker, so one checkbox carried both purposes. That is the specific pattern GDPR rejects: consent has to be granular, and current EDPB guidance treats bundling tracking consent with subscription consent as invalid because the customer cannot accept one purpose and refuse the other. Quebec's Law 25 s.8.1 is stricter again — profiling technology has to be off until the person switches it on, with no pre-ticked box and no consent inherited from agreeing to something else. Building to both standards was the decision, since the storefront is publicly reachable and anyone can register. So the marketing sentence is restored to exactly what it was, which leaves every existing email consent valid and untouched, and analytics gets its own column, its own sentence, its own checkbox at registration, its own toggle in the account page and its own endpoint. A customer can now hold either, both, or neither, and withdrawing one does not disturb the other. The migration defaults analytics_consent to false, which is both the honest answer — none of the existing customers was ever asked — and what Law 25 requires. Nothing about this change opts anybody in. Two details that are compliance requirements rather than wording preferences. The sentence names Brevo instead of saying "our email provider", because informed consent means the customer can tell who receives their data and a description they cannot act on is not disclosure. And the account toggle is as prominent and as easy to switch off as it is to switch on, because withdrawal has to be as easy as consenting. The analytics endpoint is separate from the marketing one rather than a second field on it, so that a single call cannot change an answer the customer did not touch — the bundling problem moved from the form into the API. The unit tests now assert the two consents stay apart in both directions, including that the marketing sentence still says nothing about tracking, because re-bundling them would otherwise pass silently and is the mistake this project already made once. Verified: backend tsc clean, both lint suites 0 errors with no new warnings, 478 unit tests passing across 33 suites, frontend production build green. Not verified: the migration has not been run against a database, and integration and e2e need a Node this machine does not have active. None of this is legal advice and the wording is worth a lawyer's eye before it ships. Refs #56 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ac3f6e91f5
commit
955049eac9
@@ -1,63 +1,93 @@
|
||||
import { analyticsConsent } from '../../src/routes/customers';
|
||||
import { MARKETING_CONSENT_TEXT } from '../../src/utils';
|
||||
import { ANALYTICS_CONSENT_TEXT, MARKETING_CONSENT_TEXT } from '../../src/utils';
|
||||
|
||||
/**
|
||||
* The rule this file exists for: agreeing to the *old* consent wording does not
|
||||
* authorise the Brevo tracker (#56).
|
||||
* The rule this file exists for: the Brevo tracker runs only for a customer who
|
||||
* agreed to the *analytics* sentence, and marketing consent has nothing to do
|
||||
* with it (#56).
|
||||
*
|
||||
* The sentence customers agree to was widened to mention analytics. Everyone
|
||||
* who consented before that agreed to a sentence about email and nothing else,
|
||||
* and `marketing_consent` alone cannot tell the two populations apart — which
|
||||
* is exactly why the wording is stored per customer. Getting this wrong would
|
||||
* silently track people who never agreed to it, and would do so invisibly,
|
||||
* because the flag they set really is `true`.
|
||||
* Both halves matter and both are compliance requirements rather than taste.
|
||||
* GDPR requires consent to be granular — email and tracking are separate
|
||||
* purposes with separate recipients, and current EDPB guidance treats bundling
|
||||
* them as invalid. Quebec's Law 25 s.8.1 requires profiling to be off until the
|
||||
* person switches it on, which is why the column defaults to false.
|
||||
*
|
||||
* The failure mode is silent: reading the wrong flag tracks people whose
|
||||
* marketing consent really is `true` and who never agreed to any of this.
|
||||
*/
|
||||
describe('analyticsConsent', () => {
|
||||
const OLD_WORDING =
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||
|
||||
it('is true for a customer who agreed to the current wording', () => {
|
||||
it('is true for a customer who agreed to the current analytics wording', () => {
|
||||
expect(
|
||||
analyticsConsent({ marketing_consent: true, marketing_consent_text: MARKETING_CONSENT_TEXT })
|
||||
analyticsConsent({ analytics_consent: true, analytics_consent_text: ANALYTICS_CONSENT_TEXT })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a customer who agreed to the previous, email-only wording', () => {
|
||||
// The case the whole mechanism exists for. They consented, and their
|
||||
// consent does not cover this.
|
||||
it('is false when analytics consent was never given', () => {
|
||||
// Where the migration leaves every existing customer, and where Law 25
|
||||
// requires a new one to start.
|
||||
expect(
|
||||
analyticsConsent({ marketing_consent: true, marketing_consent_text: OLD_WORDING })
|
||||
analyticsConsent({ analytics_consent: false, analytics_consent_text: null })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when consent was never given, whatever text is stored', () => {
|
||||
it('is false when the stored wording is not the current one', () => {
|
||||
// Re-wording the sentence re-asks rather than assuming. Anyone who agreed
|
||||
// to a previous version stops qualifying until they agree to this one.
|
||||
expect(
|
||||
analyticsConsent({ marketing_consent: false, marketing_consent_text: MARKETING_CONSENT_TEXT })
|
||||
analyticsConsent({ analytics_consent: true, analytics_consent_text: 'some older sentence' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when no wording was recorded at all', () => {
|
||||
// Withdrawal writes a sentinel rather than the consent text, and older rows
|
||||
// may predate the column being populated. Neither is agreement.
|
||||
it('is false when the flag is set but no wording was recorded', () => {
|
||||
expect(
|
||||
analyticsConsent({ marketing_consent: true, marketing_consent_text: null })
|
||||
analyticsConsent({ analytics_consent: true, analytics_consent_text: null })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not accept a near-miss, so a reworded sentence re-asks rather than assumes', () => {
|
||||
it('is false when withdrawal wrote its reason rather than the consent text', () => {
|
||||
expect(
|
||||
analyticsConsent({
|
||||
marketing_consent: true,
|
||||
marketing_consent_text: `${MARKETING_CONSENT_TEXT} `
|
||||
analytics_consent: false,
|
||||
analytics_consent_text: 'Withdrew analytics consent via account settings'
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('guards the wording itself: the current text must mention what is shared', () => {
|
||||
// Not a tautology — it fails if someone narrows the sentence back to email
|
||||
// while leaving the tracker gated on it, which would put this project back
|
||||
// in the position #56 was filed to get it out of.
|
||||
expect(MARKETING_CONSENT_TEXT).toContain('browse');
|
||||
expect(MARKETING_CONSENT_TEXT).not.toBe(OLD_WORDING);
|
||||
it('does not accept a near-miss', () => {
|
||||
expect(
|
||||
analyticsConsent({
|
||||
analytics_consent: true,
|
||||
analytics_consent_text: `${ANALYTICS_CONSENT_TEXT} `
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe('the two consents stay separate', () => {
|
||||
// Not a tautology. These fail if anyone re-bundles the purposes — either by
|
||||
// folding tracking back into the marketing sentence, or by pointing this
|
||||
// function at the marketing columns — which is the specific pattern GDPR
|
||||
// and Law 25 both reject, and which this project shipped once already
|
||||
// before it was caught.
|
||||
it('marketing consent alone does not authorise tracking', () => {
|
||||
expect(
|
||||
analyticsConsent({ analytics_consent: false, analytics_consent_text: MARKETING_CONSENT_TEXT })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('the marketing sentence says nothing about tracking', () => {
|
||||
expect(MARKETING_CONSENT_TEXT).not.toContain('browse');
|
||||
expect(MARKETING_CONSENT_TEXT).not.toContain('Brevo');
|
||||
});
|
||||
|
||||
it('the analytics sentence names the recipient and says it is optional', () => {
|
||||
// Informed consent means the customer can tell who receives their data;
|
||||
// "our email provider" is not something they can act on.
|
||||
expect(ANALYTICS_CONSENT_TEXT).toContain('Brevo');
|
||||
expect(ANALYTICS_CONSENT_TEXT).toContain('optional');
|
||||
});
|
||||
|
||||
it('the two sentences are not the same string', () => {
|
||||
expect(ANALYTICS_CONSENT_TEXT).not.toBe(MARKETING_CONSENT_TEXT);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user