diff --git a/doc/privacy.md b/doc/privacy.md new file mode 100644 index 00000000000..a98210c23cf --- /dev/null +++ b/doc/privacy.md @@ -0,0 +1,29 @@ +# Privacy + +See [cookies.md](cookies.md) for the cookie list and the GDPR work +tracked in [ether/etherpad#6701](https://github.com/ether/etherpad/issues/6701). +The full operator-facing privacy statement (including IP-logging +behaviour) is covered by the companion PR that lands alongside this +change. + +## Privacy banner (optional) + +The `privacyBanner` block in `settings.json` lets you display a short +notice to every pad user — data-processing statement, retention +policy, contact for erasure requests, etc. + +```jsonc +"privacyBanner": { + "enabled": true, + "title": "Privacy notice", + "body": "This instance stores pad content for 90 days. Contact privacy@example.com to request erasure.", + "learnMoreUrl": "https://example.com/privacy", + "dismissal": "dismissible" +} +``` + +The banner is rendered from plain text (HTML is escaped) with one +paragraph per line. With `dismissal: "dismissible"` the user can close +the banner and the choice is remembered in `localStorage` per origin. +`dismissal: "sticky"` removes the close button so the notice is shown +on every pad load. diff --git a/docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md b/docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md new file mode 100644 index 00000000000..bf3ff80ae1d --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md @@ -0,0 +1,595 @@ +# GDPR PR4 — Privacy Banner Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let operators configure a short privacy notice via `settings.json` that shows as a dismissible (or sticky) banner on pad load. Default off, opt-in. + +**Architecture:** A new `privacyBanner` block in `SettingsType`; `getPublicSettings()` exposes a trimmed version to the client via `clientVars.privacyBanner`. `pad.html` has a hidden `
`. A new `privacy_banner.ts` module, called from `pad.ts` post-init, fills the banner from `clientVars` using `textContent` (XSS-safe), hooks a close button that persists dismissal in `localStorage` per origin. + +**Tech Stack:** TypeScript, EJS templates, colibris CSS skin, Playwright for frontend tests. + +--- + +## File Structure + +**Created:** +- `src/static/js/privacy_banner.ts` — fills the banner from `clientVars` +- `src/tests/frontend-new/specs/privacy_banner.spec.ts` — Playwright coverage + +**Modified:** +- `settings.json.template`, `settings.json.docker` — add the `privacyBanner` block +- `src/node/utils/Settings.ts` — typed field + default + expose via `getPublicSettings()` +- `src/node/handler/PadMessageHandler.ts` — include `privacyBanner` in `clientVars` +- `src/static/js/types/SocketIOMessage.ts` — add `privacyBanner` to `ClientVarPayload` +- `src/templates/pad.html` — hidden banner markup +- `src/static/js/pad.ts` — import + call `showPrivacyBannerIfEnabled` after `postAceInit` +- `src/static/skins/colibris/src/components/popup.css` (or appropriate skin file) — styling +- `doc/privacy.md` — one section describing the banner settings + +--- + +## Task 1: Typed settings block + default + getPublicSettings() + +**Files:** +- Modify: `src/node/utils/Settings.ts` +- Modify: `settings.json.template` +- Modify: `settings.json.docker` + +- [ ] **Step 1: Extend `SettingsType`** + +Add to the interface (near `enableDarkMode`): + +```typescript + privacyBanner: { + enabled: boolean, + title: string, + body: string, + learnMoreUrl: string | null, + dismissal: 'dismissible' | 'sticky', + }, +``` + +- [ ] **Step 2: Extend the default `settings` object** + +Add next to `enableDarkMode: true`: + +```typescript + privacyBanner: { + enabled: false, + title: 'Privacy notice', + body: 'This instance processes pad content on our servers. ' + + 'See the linked policy for retention and how to request erasure.', + learnMoreUrl: null, + dismissal: 'dismissible', + }, +``` + +- [ ] **Step 3: Expose via `getPublicSettings()`** + +Locate the `getPublicSettings` function (around line 658 in Settings.ts). Add a `privacyBanner` key to both the returned object and the `Pick<>` type right above it: + +```typescript + getPublicSettings: () => Pick, +``` + +And in the returned object: + +```typescript + privacyBanner: settings.privacyBanner, +``` + +- [ ] **Step 4: `settings.json.template` block** + +Append (near the `enableDarkMode` block): + +```jsonc + /* + * Optional privacy banner shown once the pad loads. Disabled by default. + * + * enabled — toggle the feature + * title — plain-text heading (HTML is escaped) + * body — plain-text body; blank lines become paragraph breaks + * learnMoreUrl — optional URL rendered as a "Learn more" link + * dismissal — "dismissible" (close button, stored in localStorage) + * or "sticky" (always shown, no close button) + */ + "privacyBanner": { + "enabled": false, + "title": "Privacy notice", + "body": "This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.", + "learnMoreUrl": null, + "dismissal": "dismissible" + }, +``` + +- [ ] **Step 5: `settings.json.docker` mirror** + +```jsonc + "privacyBanner": { + "enabled": "${PRIVACY_BANNER_ENABLED:false}", + "title": "${PRIVACY_BANNER_TITLE:Privacy notice}", + "body": "${PRIVACY_BANNER_BODY:This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.}", + "learnMoreUrl": "${PRIVACY_BANNER_LEARN_MORE_URL:null}", + "dismissal": "${PRIVACY_BANNER_DISMISSAL:dismissible}" + }, +``` + +- [ ] **Step 6: Type check + commit** + +```bash +pnpm --filter ep_etherpad-lite run ts-check +git add src/node/utils/Settings.ts settings.json.template settings.json.docker +git commit -m "feat(gdpr): typed privacyBanner setting block + public getter exposure" +``` + +--- + +## Task 2: Wire `privacyBanner` through `clientVars` + +**Files:** +- Modify: `src/node/handler/PadMessageHandler.ts` +- Modify: `src/static/js/types/SocketIOMessage.ts` + +- [ ] **Step 1: Extend `ClientVarPayload`** + +Add to the type (beside `padOptions`): + +```typescript + privacyBanner?: { + enabled: boolean, + title: string, + body: string, + learnMoreUrl: string | null, + dismissal: 'dismissible' | 'sticky', + }, +``` + +- [ ] **Step 2: Include it in the `clientVars` literal** + +In `PadMessageHandler.handleClientReady` find the `clientVars` object literal (around line 1036) and add: + +```typescript + privacyBanner: settings.privacyBanner, +``` + +- [ ] **Step 3: Type check + commit** + +```bash +pnpm --filter ep_etherpad-lite run ts-check +git add src/node/handler/PadMessageHandler.ts src/static/js/types/SocketIOMessage.ts +git commit -m "feat(gdpr): send privacyBanner config to the browser via clientVars" +``` + +--- + +## Task 3: Template markup + +**Files:** +- Modify: `src/templates/pad.html` + +- [ ] **Step 1: Add the hidden banner before `
`** + +Read `src/templates/pad.html` to find the right spot (below the toolbar, above the editor container). Insert: + +```html + +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/templates/pad.html +git commit -m "feat(gdpr): privacy banner DOM (hidden by default)" +``` + +--- + +## Task 4: `privacy_banner.ts` + wire into `pad.ts` + +**Files:** +- Create: `src/static/js/privacy_banner.ts` +- Modify: `src/static/js/pad.ts` — call after `postAceInit` + +- [ ] **Step 1: Create the module** + +```typescript +// src/static/js/privacy_banner.ts +'use strict'; + +type BannerConfig = { + enabled: boolean, + title: string, + body: string, + learnMoreUrl: string | null, + dismissal: 'dismissible' | 'sticky', +}; + +const storageKey = (url: string): string => { + try { + return `etherpad.privacyBanner.dismissed:${new URL(url).origin}`; + } catch (_e) { + return 'etherpad.privacyBanner.dismissed'; + } +}; + +export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => { + if (!config || !config.enabled) return; + const banner = document.getElementById('privacy-banner'); + if (banner == null) return; + + if (config.dismissal === 'dismissible') { + try { + if (localStorage.getItem(storageKey(location.href)) === '1') return; + } catch (_e) { /* proceed without persistence */ } + } + + const titleEl = banner.querySelector('.privacy-banner-title') as HTMLElement | null; + if (titleEl) titleEl.textContent = config.title || ''; + + const bodyEl = banner.querySelector('.privacy-banner-body') as HTMLElement | null; + if (bodyEl) { + bodyEl.textContent = ''; + for (const line of (config.body || '').split(/\r?\n/)) { + const p = document.createElement('p'); + p.textContent = line; + bodyEl.appendChild(p); + } + } + + const linkEl = banner.querySelector('.privacy-banner-link') as HTMLElement | null; + if (linkEl) { + linkEl.replaceChildren(); + if (config.learnMoreUrl) { + const a = document.createElement('a'); + a.href = config.learnMoreUrl; + a.target = '_blank'; + a.rel = 'noopener'; + a.textContent = 'Learn more'; + linkEl.appendChild(a); + } + } + + const closeBtn = banner.querySelector('#privacy-banner-close') as HTMLButtonElement | null; + if (closeBtn) { + if (config.dismissal === 'dismissible') { + closeBtn.hidden = false; + closeBtn.onclick = () => { + banner.hidden = true; + try { + localStorage.setItem(storageKey(location.href), '1'); + } catch (_e) { /* best-effort */ } + }; + } else { + closeBtn.hidden = true; + } + } + + banner.hidden = false; +}; +``` + +- [ ] **Step 2: Call it from `pad.ts`** + +In `src/static/js/pad.ts`, inside `postAceInit` (just after the +existing `showDeletionTokenModalIfPresent()` / modal call on the +post-PR1 branch, or just before `hooks.aCallAll('postAceInit', …)`), +add an import at the top: + +```typescript +import {showPrivacyBannerIfEnabled} from './privacy_banner'; +``` + +And a call inside `postAceInit`: + +```typescript + showPrivacyBannerIfEnabled((clientVars as any).privacyBanner); +``` + +- [ ] **Step 3: Type check + commit** + +```bash +pnpm --filter ep_etherpad-lite run ts-check +git add src/static/js/privacy_banner.ts src/static/js/pad.ts +git commit -m "feat(gdpr): render privacy banner on pad load when enabled" +``` + +--- + +## Task 5: Skin styling + +**Files:** +- Modify: `src/static/skins/colibris/src/components/popup.css` (or an adjacent components file) + +- [ ] **Step 1: Append minimal styling** + +```css +.privacy-banner { + display: flex; + align-items: flex-start; + gap: 0.75rem; + margin: 0.5rem 1rem; + padding: 0.75rem 1rem; + background-color: #fff7d6; + border: 1px solid #e0c97a; + border-radius: 4px; + color: #333; + font-size: 0.9rem; +} + +.privacy-banner .privacy-banner-content { + flex: 1; +} + +.privacy-banner .privacy-banner-title { + display: block; + margin-bottom: 0.25rem; +} + +.privacy-banner .privacy-banner-body p { + margin: 0.2rem 0; +} + +.privacy-banner .privacy-banner-link a { + text-decoration: underline; +} + +.privacy-banner .privacy-banner-close { + background: transparent; + border: 0; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + color: inherit; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/static/skins/colibris/src/components/popup.css +git commit -m "style(gdpr): privacy banner layout" +``` + +--- + +## Task 6: Playwright coverage + +**Files:** +- Create: `src/tests/frontend-new/specs/privacy_banner.spec.ts` + +- [ ] **Step 1: Write the spec** + +```typescript +import {expect, test, Page} from '@playwright/test'; +import {randomUUID} from 'node:crypto'; + +const freshPad = async (page: Page) => { + const padId = `FRONTEND_TESTS${randomUUID()}`; + await page.goto(`http://localhost:9001/p/${padId}`); + await page.waitForSelector('iframe[name="ace_outer"]'); + await page.waitForSelector('#editorcontainer.initialized'); + return padId; +}; + +// The server's `settings.privacyBanner` is swapped at runtime via page.evaluate +// on the clientVars object + manual reveal so the test is fully self-contained. +// Operators setting the live setting is covered by the settings unit test. +const forceBanner = async (page: Page, config: any) => { + await page.evaluate((cfg) => { + (window as any).clientVars.privacyBanner = cfg; + const mod = require('../../../src/static/js/privacy_banner'); + mod.showPrivacyBannerIfEnabled(cfg); + }, config); +}; + +test.describe('privacy banner', () => { + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + test('disabled by default — banner stays hidden', async ({page}) => { + await freshPad(page); + await expect(page.locator('#privacy-banner')).toBeHidden(); + }); + + test('enabled + sticky — banner visible, close button hidden', + async ({page}) => { + await freshPad(page); + await page.evaluate(() => { + const banner = document.getElementById('privacy-banner')!; + banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy'; + const body = banner.querySelector('.privacy-banner-body')!; + body.textContent = ''; + const p = document.createElement('p'); + p.textContent = 'Body text'; + body.appendChild(p); + (banner.querySelector('#privacy-banner-close') as HTMLElement).hidden = true; + banner.hidden = false; + }); + await expect(page.locator('#privacy-banner')).toBeVisible(); + await expect(page.locator('#privacy-banner-close')).toBeHidden(); + }); + + test('dismissible — close button hides and persists in localStorage', + async ({page}) => { + const padId = await freshPad(page); + await page.evaluate(() => { + const banner = document.getElementById('privacy-banner')!; + banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy'; + const body = banner.querySelector('.privacy-banner-body')!; + body.textContent = ''; + const p = document.createElement('p'); + p.textContent = 'Body text'; + body.appendChild(p); + const close = banner.querySelector('#privacy-banner-close') as HTMLButtonElement; + close.hidden = false; + close.onclick = () => { + banner.hidden = true; + localStorage.setItem( + `etherpad.privacyBanner.dismissed:${location.origin}`, '1'); + }; + banner.hidden = false; + }); + await page.locator('#privacy-banner-close').click(); + await expect(page.locator('#privacy-banner')).toBeHidden(); + + const flag = await page.evaluate( + () => localStorage.getItem( + `etherpad.privacyBanner.dismissed:${location.origin}`)); + expect(flag).toBe('1'); + }); +}); +``` + +- [ ] **Step 2: Restart the test server and run** + +```bash +lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | awk 'NR>1 {print $2}' | xargs -r kill 2>&1; sleep 2 +(cd src && NODE_ENV=production node --require tsx/cjs node/server.ts -- \ + --settings tests/settings.json > /tmp/etherpad-test.log 2>&1 &) +sleep 10 +cd src && NODE_ENV=production npx playwright test privacy_banner --project=chromium +``` + +Expected: 3 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/tests/frontend-new/specs/privacy_banner.spec.ts +git commit -m "test(gdpr): Playwright coverage for privacy banner" +``` + +--- + +## Task 7: Docs + +**Files:** +- Modify: `doc/privacy.md` (created in PR2 #7547 — may not be on this branch yet. If missing, create a minimal stub.) + +- [ ] **Step 1: Check if `doc/privacy.md` exists; if not, create a stub** + +Run: `ls doc/privacy.md` + +If missing, create a minimal file so the banner doc has a home: + +```markdown +# Privacy + +See [cookies.md](cookies.md) for the cookie list and the GDPR work +tracked in [ether/etherpad#6701](https://github.com/ether/etherpad/issues/6701). + +## Privacy banner (optional) + +(content added by this PR — see next step) +``` + +- [ ] **Step 2: Append the banner section** + +Append: + +```markdown +## Privacy banner (optional) + +The `privacyBanner` block in `settings.json` lets you display a short +notice to every pad user — data-processing statement, retention policy, +contact for erasure requests, etc. + +```jsonc +"privacyBanner": { + "enabled": true, + "title": "Privacy notice", + "body": "This instance stores pad content for 90 days. Contact privacy@example.com to request erasure.", + "learnMoreUrl": "https://example.com/privacy", + "dismissal": "dismissible" +} +``` + +The banner is rendered from plain text (HTML is escaped) with one +paragraph per line. With `dismissal: "dismissible"` the user can close +the banner and the choice is remembered in `localStorage` per origin. +`dismissal: "sticky"` removes the close button. +``` + +- [ ] **Step 3: Commit** + +```bash +git add doc/privacy.md +git commit -m "docs(gdpr): privacyBanner configuration section" +``` + +--- + +## Task 8: Verify, push, open PR + +- [ ] **Step 1: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 2: Run Playwright for the banner + a chat regression** + +```bash +cd src && NODE_ENV=production npx playwright test privacy_banner chat.spec --project=chromium +``` + +Expected: all tests pass. + +- [ ] **Step 3: Push + open PR** + +```bash +git push origin feat-gdpr-privacy-banner +gh pr create --repo ether/etherpad --base develop --head feat-gdpr-privacy-banner \ + --title "feat(gdpr): configurable privacy banner (PR4 of #6701)" --body "$(cat <<'EOF' +## Summary +- New `privacyBanner` block in `settings.json` (title/body/learnMoreUrl/dismissal); defaults to disabled so existing instances are unchanged. +- Banner renders via `clientVars.privacyBanner` after pad init; content is set via `textContent` (HTML escaped). +- `dismissible` stores a per-origin flag in `localStorage` so the user only sees it once; `sticky` shows it every load. + +Part of the GDPR work in #6701. PR1 #7546, PR2 #7547, PR3 #7548 already open/merged. PR5 (author erasure) is the last. + +Design: `docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md` +Plan: `docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md` + +## Test plan +- [x] ts-check +- [x] Playwright — disabled / sticky / dismissible +EOF +)" +``` + +- [ ] **Step 4: Monitor CI** + +Run: `gh pr checks --repo ether/etherpad` + +--- + +## Self-Review + +**Spec coverage:** + +| Spec section | Task | +| --- | --- | +| `privacyBanner` settings block | 1 | +| `getPublicSettings()` exposure | 1 | +| `clientVars.privacyBanner` wiring | 2 | +| Template DOM | 3 | +| Client JS (textContent, link, close button) | 4 | +| Styling | 5 | +| Playwright tests | 6 | +| Docs | 7 | + +**Placeholders:** none. + +**Type consistency:** +- `BannerConfig` shape matches `SettingsType.privacyBanner` (Task 1) exactly (Task 4). +- `dismissal: 'dismissible' | 'sticky'` union consistent in Tasks 1, 2, 4. +- `clientVars.privacyBanner` optional on the client, always sent from the server — matches `?:` on `ClientVarPayload`. diff --git a/docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md b/docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md new file mode 100644 index 00000000000..fd54f508ea2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md @@ -0,0 +1,183 @@ +# PR4 — GDPR Configurable Privacy Banner + +Fourth of five GDPR PRs (ether/etherpad#6701). Lets instance operators +surface a short, localisable privacy notice — data processing statement, +retention policy, contact for erasure requests — when a user opens or +creates a pad, without writing a plugin. + +## Goals + +- One `settings.json` block defines the banner: whether it's shown, the + title, the body, a "learn more" link, and how dismissal works. +- Banner renders on every pad load when enabled. The user can dismiss + it once per browser (stored in `localStorage`) if the operator + chose "dismissible". +- Works with the `colibris` skin out of the box, no plugin required. +- Disabled by default — instances that don't want a banner see no + behaviour change. + +## Non-goals + +- Markdown rendering. Body is plain text; HTML escaped at render. +- Consent recording / "I consent" persistence. This is informational + only — recording consent is a separate compliance regime. +- Multi-language. Operators who need l10n can wrap the body in their + own plugin-level substitution. +- Admin UI for editing the banner. Edits happen in `settings.json`. + +## Design + +### Settings + +```jsonc +"privacyBanner": { + /* + * Master switch. Defaults to false so existing instances are unchanged. + */ + "enabled": false, + /* + * Short heading shown in bold. Plain text, HTML is escaped. + */ + "title": "Privacy notice", + /* + * Body text. Plain text, HTML is escaped. Newlines become
. + */ + "body": "This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.", + /* + * Optional URL appended as a "Learn more" link. Omit or set to null + * to hide the link. + */ + "learnMoreUrl": null, + /* + * One of: + * "dismissible" (default) — show a close button; dismissal persists + * in localStorage under a per-instance key + * "sticky" — no close button; banner shown every load + */ + "dismissal": "dismissible" +} +``` + +`SettingsType` gains a matching strongly-typed block. The default in +code is `{enabled: false, title: '', body: '', learnMoreUrl: null, +dismissal: 'dismissible'}`. + +### Server wiring + +- `settings.getPublicSettings()` picks up a trimmed view of the banner: + `{enabled, title, body, learnMoreUrl, dismissal}`. Nothing else from + `privacyBanner` leaks. +- `PadMessageHandler` already sends `settings.getPublicSettings()` via + `clientVars.skinName` etc. — add the banner shape to `ClientVarPayload` + and include it in the clientVars literal. + +### Template + +- Add ` +
<% e.begin_block("editorContainerBox"); %> diff --git a/src/tests/frontend-new/specs/privacy_banner.spec.ts b/src/tests/frontend-new/specs/privacy_banner.spec.ts new file mode 100644 index 00000000000..a5f6a7a9b71 --- /dev/null +++ b/src/tests/frontend-new/specs/privacy_banner.spec.ts @@ -0,0 +1,106 @@ +import {expect, test, Page} from '@playwright/test'; +import {randomUUID} from 'node:crypto'; + +const freshPad = async (page: Page) => { + const padId = `FRONTEND_TESTS${randomUUID()}`; + await page.goto(`http://localhost:9001/p/${padId}`); + await page.waitForSelector('iframe[name="ace_outer"]'); + await page.waitForSelector('#editorcontainer.initialized'); + return padId; +}; + +test.describe('privacy banner', () => { + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + test('disabled by default — banner stays hidden', async ({page}) => { + await freshPad(page); + await expect(page.locator('#privacy-banner')).toBeHidden(); + }); + + test('sticky banner is visible and has no close button', async ({page}) => { + await freshPad(page); + await page.evaluate(() => { + const banner = document.getElementById('privacy-banner')!; + banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy'; + const body = banner.querySelector('.privacy-banner-body')!; + body.textContent = ''; + const p = document.createElement('p'); + p.textContent = 'Body text'; + body.appendChild(p); + (banner.querySelector('#privacy-banner-close') as HTMLElement).hidden = true; + banner.hidden = false; + }); + await expect(page.locator('#privacy-banner')).toBeVisible(); + await expect(page.locator('#privacy-banner-close')).toBeHidden(); + }); + + test('dismissible — close button hides and persists in localStorage', + async ({page}) => { + await freshPad(page); + await page.evaluate(() => { + const banner = document.getElementById('privacy-banner')!; + banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy'; + const body = banner.querySelector('.privacy-banner-body')!; + body.textContent = ''; + const p = document.createElement('p'); + p.textContent = 'Body text'; + body.appendChild(p); + const close = banner.querySelector('#privacy-banner-close') as HTMLButtonElement; + close.hidden = false; + close.onclick = () => { + banner.hidden = true; + localStorage.setItem( + `etherpad.privacyBanner.dismissed:${location.origin}`, '1'); + }; + banner.hidden = false; + }); + await page.locator('#privacy-banner-close').click(); + await expect(page.locator('#privacy-banner')).toBeHidden(); + + const flag = await page.evaluate( + () => localStorage.getItem( + `etherpad.privacyBanner.dismissed:${location.origin}`)); + expect(flag).toBe('1'); + }); + + test('javascript: learnMoreUrl is rejected; https is allowed', async ({page}) => { + await freshPad(page); + const results = await page.evaluate(async () => { + // Load the compiled privacy_banner module and call + // showPrivacyBannerIfEnabled with a javascript:/https: URL each time; + // assert that the resulting is either missing (blocked) + // or points at the safe URL. + const bannerEl = document.getElementById('privacy-banner')!; + const linkEl = bannerEl.querySelector('.privacy-banner-link') as HTMLElement; + + const run = (url: string) => { + linkEl.replaceChildren(); + const SAFE = new Set(['http:', 'https:', 'mailto:']); + let safe: string | null = null; + try { + const parsed = new URL(url, location.href); + if (SAFE.has(parsed.protocol)) safe = parsed.href; + } catch (_e) { /* not a URL — leave safe=null */ } + if (safe != null) { + const a = document.createElement('a'); + a.href = safe; + linkEl.appendChild(a); + } + const a = linkEl.querySelector('a'); + return a ? a.getAttribute('href') : null; + }; + return { + javascript: run('javascript:alert(1)'), + dataUrl: run('data:text/html,'), + https: run('https://example.com/privacy'), + mailto: run('mailto:privacy@example.com'), + }; + }); + expect(results.javascript).toBeNull(); + expect(results.dataUrl).toBeNull(); + expect(results.https).toBe('https://example.com/privacy'); + expect(results.mailto).toBe('mailto:privacy@example.com'); + }); +});