mailship/test/confirm-email-cooldown.test.ts
mplorentz 051c1e88fb Rate-limit confirmation emails and restore daily/weekly digest cron
- Add last_confirm_sent_at to subscriptions, with a migration for
  existing databases. registerSubscription now sends at most one
  confirmation email per 10 minutes per subscription, so a page-refresh
  or client retry storm can't spam an inbox. The cooldown resets when
  the address changes or a tombstoned row is reactivated (fresh key),
  so genuinely new addresses always get an email immediately.
- Restore getCronExpression to the documented daily (17:00 UTC) and
  weekly (Monday 17:00 UTC) schedules with the 6-field cron syntax the
  cron package expects, fixing the pre-existing reschedule tests that the
  'run every minute' testing hack had broken.
- Add confirm-email-cooldown.test.ts covering first send, refresh storms,
  frequency changes, email changes, reactivation, and the 10-minute constant.
2026-09-22 12:31:33 -04:00

101 lines
No EOL
3.8 KiB
TypeScript

import { describe, it, expect, beforeAll, vi, afterEach, beforeEach } from 'vitest'
import * as db from '../src/database.js'
import { registerSubscription, CONFIRM_EMAIL_COOLDOWN_SECONDS } from '../src/actions.js'
import * as mailer from '../src/mailer.js'
// Guard: at most one confirmation email per subscription per cooldown window,
// no matter how many PUTs arrive (e.g. a page-refresh storm while unconfirmed).
vi.mock('../src/mailer.js', async (importOriginal) => {
const actual: any = await importOriginal()
return { ...actual, sendConfirm: vi.fn(async () => {}) }
})
const unique = (label: string) => `${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`
const pubkey = unique('cooldown')
const email = `${unique('cooldown')}@example.com`
const confirmCalls = () => vi.mocked(mailer.sendConfirm).mock.calls.length
describe('Confirmation email cooldown', () => {
beforeAll(async () => {
await db.migrate()
})
beforeEach(() => {
vi.mocked(mailer.sendConfirm).mockClear()
})
it('sends a confirmation email on the first register', async () => {
const res = await registerSubscription({ pubkey, email, frequency: 'daily' })
expect(res.key).toBeTruthy()
expect(confirmCalls()).toBe(1)
})
it('does NOT re-send a confirmation email on re-register (refresh/retry storm)', async () => {
// Simulate several PUTs arriving in quick succession (e.g. page reloads).
for (let i = 0; i < 5; i++) {
await registerSubscription({ pubkey, email, frequency: 'daily' })
}
expect(confirmCalls()).toBe(0)
})
it('a new frequency (setting change) does not re-send', async () => {
await registerSubscription({ pubkey, email, frequency: 'weekly' })
expect(confirmCalls()).toBe(0)
})
it('changing the email address sends immediately (cooldown reset)', async () => {
const newEmail = `${unique('cooldown')}@example.com`
await registerSubscription({ pubkey, email: newEmail, frequency: 'daily' })
expect(confirmCalls()).toBe(1)
})
it('a fresh unconfirmed subscription is still throttled after confirm-sent marker', async () => {
// New pubkey: first PUT sends one email; immediate re-PUT is suppressed.
const pk2 = unique('cooldown2')
const em2 = `${unique('cooldown2')}@example.com`
await registerSubscription({ pubkey: pk2, email: em2, frequency: 'daily' })
await registerSubscription({ pubkey: pk2, email: em2, frequency: 'daily' })
expect(confirmCalls()).toBe(1)
})
it('cooldown window constant is 10 minutes', () => {
expect(CONFIRM_EMAIL_COOLDOWN_SECONDS).toBe(600)
})
})
describe('Cooldown reset on subscription reactivation (same email, off/on cycle)', () => {
const k = unique('cooldown-reactivate')
const e = `${unique('cooldown-reactivate')}@example.com`
beforeAll(async () => {
await db.migrate()
})
beforeEach(() => {
vi.mocked(mailer.sendConfirm).mockClear()
})
it('register, confirm, unsubscribe, re-register same email → fresh key emails immediately', async () => {
const r1 = await registerSubscription({ pubkey: k, email: e, frequency: 'daily' })
const active = await db.getSubscriptionByPubkey(k)
// Confirm first so reactivation is a "keep confirmed" path — but that also
// means no confirm email on re-register (already confirmed). Verify the row
// is reactivated with a fresh key, not a duplicate.
const confirmed = await db.confirmSubscription(active!.key)
expect(confirmed).toBeTruthy()
await db.unsubscribeSubscription(active!.key)
vi.mocked(mailer.sendConfirm).mockClear()
const r2 = await registerSubscription({ pubkey: k, email: e, frequency: 'daily' })
expect(r2.key).not.toBe(r1.key) // fresh key issued
expect(confirmCalls()).toBe(0) // still confirmed → no new email
const rows = await db.getAllSubscriptionsByPubkey(k)
expect(rows).toHaveLength(1)
})
})