diff --git a/apps/site/functions/account/mailing-list/index.ts b/apps/site/functions/account/mailing-list/index.ts index 34b8b5dc..cb7b281d 100644 --- a/apps/site/functions/account/mailing-list/index.ts +++ b/apps/site/functions/account/mailing-list/index.ts @@ -18,8 +18,8 @@ export const onRequestPost: PagesFunction = async ({ env, reques if (signed instanceof Response) return signed; const action = field(await formOf(request), 'action'); if (action === 'leave') { - await leaveMailingList(signed.env, signed.person.id); - return backToAccount('left'); + const outcome = await leaveMailingList(signed.env, signed.person.id); + return backToAccount(outcome); } if (action === 'rejoin') { const outcome = await rejoinMailingList(signed.env, signed.person); diff --git a/apps/site/platform/account.ts b/apps/site/platform/account.ts index 9997efef..d2555e4c 100644 --- a/apps/site/platform/account.ts +++ b/apps/site/platform/account.ts @@ -278,15 +278,18 @@ export async function unsubscribeEverywhere( env: AccountEnv, personId: string, fetcher: typeof fetch = fetch, -): Promise { +): Promise { + const ids = await beehiivIds(env.PLATFORM_DB, personId); + if (!ids.length) return true; const config = beehiiv(env); if (!config) { console.error('account: Beehiiv secrets are missing, so the unsubscribe is not sent'); - return; + return false; } - for (const id of await beehiivIds(env.PLATFORM_DB, personId)) { - await unsubscribe(config, id, fetcher); + for (const id of ids) { + if (!(await unsubscribe(config, id, fetcher))) return false; } + return true; } async function subscribeAddress( @@ -332,13 +335,14 @@ export async function leaveMailingList( env: AccountEnv, personId: string, fetcher: typeof fetch = fetch, -): Promise { +): Promise<'left' | 'unavailable'> { + if (!(await unsubscribeEverywhere(env, personId, fetcher))) return 'unavailable'; await new PersonService(env.PLATFORM_DB).withdrawConsent(personId, { scope: 'newsletter', source: 'account', withdrawnAt: nowIso(), }); - await unsubscribeEverywhere(env, personId, fetcher); + return 'left'; } export async function rejoinMailingList( diff --git a/apps/site/tests/platform-account.test.ts b/apps/site/tests/platform-account.test.ts index e8e7ddf3..19ef7e3a 100644 --- a/apps/site/tests/platform-account.test.ts +++ b/apps/site/tests/platform-account.test.ts @@ -185,7 +185,7 @@ void test('leaving makes a former member, and rejoining records a new consent', const db = memoryDb(); const person = await member(db); const { fetcher, calls } = fakeServices(); - await leaveMailingList(env(db), person.id, fetcher); + assert.equal(await leaveMailingList(env(db), person.id, fetcher), 'left'); assert.equal( (await new PersonService(db).getPerson(person.id))?.membership_status, 'former_member', @@ -203,6 +203,24 @@ void test('leaving makes a former member, and rejoining records a new consent', ); }); +void test('leaving remains retryable when the mailing provider fails', async () => { + for (const failure of ['rejected', 'network', 'missing_config']) { + const db = memoryDb(); + const person = await member(db); + const config = env(db); + if (failure === 'missing_config') delete config.LVBT_BEEHIIV_API_KEY; + const fetcher = (() => + failure === 'network' + ? Promise.reject(new Error('offline')) + : Promise.resolve(new Response(null, { status: 503 }))) as typeof fetch; + assert.equal(await leaveMailingList(config, person.id, fetcher), 'unavailable'); + assert.equal((await accountView(db, person.id))?.onMailingList, true); + assert.equal((await new PersonService(db).getPerson(person.id))?.membership_status, 'member'); + assert.equal(await leaveMailingList(env(db), person.id, fakeServices().fetcher), 'left'); + assert.equal((await accountView(db, person.id))?.onMailingList, false); + } +}); + void test('the download holds everything about this member and nothing about anyone else', async () => { const db = memoryDb(); const person = await member(db); diff --git a/apps/site/tests/platform-unsubscribe.test.ts b/apps/site/tests/platform-unsubscribe.test.ts new file mode 100644 index 00000000..060b311a --- /dev/null +++ b/apps/site/tests/platform-unsubscribe.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { onRequestPost } from '../functions/join/remove'; +import { signToken } from '../platform/core/signing'; +import { PersonService } from '../platform/storage/person-service'; +import { memoryDb } from './support/platform-db'; + +void test('one-click unsubscribe needs no session and updates the mailing list and consent', async (t) => { + const db = memoryDb(); + const people = new PersonService(db); + const { person } = await people.upsertFromSource({ + source: 'join_form', + fields: { email: 'one-click@example.org' }, + consent: { + scope: 'newsletter', + source: 'join_form', + method: 'checkbox', + wordingVersion: 'join-form-v1', + }, + }); + await people.linkIdentity(person.id, { + platform: 'beehiiv', + externalId: 'sub_one_click', + externalEmail: 'one-click@example.org', + linkMethod: 'created_by_platform', + }); + const token = await signToken('test-secret', { + purpose: 'remove_email', + subject: person.id, + expiresAt: Date.now() + 60_000, + }); + const invoke = () => + onRequestPost({ + env: { + PLATFORM_DB: db, + LVBT_LINK_SIGNING_SECRET: 'test-secret', + LVBT_BEEHIIV_API_KEY: 'test-api-key', + LVBT_BEEHIIV_PUBLICATION_ID: 'pub_test', + }, + request: new Request(`https://lasvegasfortransit.org/join/remove/?token=${token}`, { + method: 'POST', + body: new URLSearchParams({ 'List-Unsubscribe': 'One-Click' }), + }), + } as unknown as Parameters[0]); + + t.mock.method(globalThis, 'fetch', () => Promise.resolve(new Response(null, { status: 503 }))); + assert.equal((await invoke()).status, 503); + assert.equal((await people.getPerson(person.id))?.membership_status, 'member'); + assert.equal( + db.raw.prepare('SELECT withdrawn_at FROM consent_records').get()?.withdrawn_at, + null, + ); + + t.mock.method(globalThis, 'fetch', (_url: string, init: RequestInit) => { + assert.equal(init.method, 'PATCH'); + assert.ok(typeof init.body === 'string'); + assert.deepEqual(JSON.parse(init.body), { unsubscribe: true }); + return Promise.resolve(new Response(null, { status: 200 })); + }); + assert.equal((await invoke()).status, 200); + assert.ok(db.raw.prepare('SELECT withdrawn_at FROM consent_records').get()?.withdrawn_at); + assert.equal(await people.getPerson(person.id), null); + assert.equal(db.raw.prepare('SELECT email FROM people').get()?.email, null); +}); + +void test('one-click unsubscribe rejects forged links', async () => { + const response = await onRequestPost({ + env: { PLATFORM_DB: memoryDb(), LVBT_LINK_SIGNING_SECRET: 'test-secret' }, + request: new Request('https://lasvegasfortransit.org/join/remove/?token=forged.token', { + method: 'POST', + body: new URLSearchParams({ 'List-Unsubscribe': 'One-Click' }), + }), + } as unknown as Parameters[0]); + assert.equal(response.status, 400); +});