Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/site/functions/account/mailing-list/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export const onRequestPost: PagesFunction<SignInPagesEnv> = 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);
Expand Down
16 changes: 10 additions & 6 deletions apps/site/platform/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,15 +278,18 @@ export async function unsubscribeEverywhere(
env: AccountEnv,
personId: string,
fetcher: typeof fetch = fetch,
): Promise<void> {
): Promise<boolean> {
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(
Expand Down Expand Up @@ -332,13 +335,14 @@ export async function leaveMailingList(
env: AccountEnv,
personId: string,
fetcher: typeof fetch = fetch,
): Promise<void> {
): 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(
Expand Down
20 changes: 19 additions & 1 deletion apps/site/tests/platform-account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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);
Expand Down
75 changes: 75 additions & 0 deletions apps/site/tests/platform-unsubscribe.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof onRequestPost>[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<typeof onRequestPost>[0]);
assert.equal(response.status, 400);
});
Loading