/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {Config} from '@fluxer/api/src/Config'; import {DonationRepository} from '@fluxer/api/src/donation/DonationRepository'; import {DonorMagicLinkToken} from '@fluxer/api/src/donation/models/DonorMagicLinkToken'; import { createDonationManageBuilder, TEST_DONOR_EMAIL, TEST_INVALID_TOKEN, TEST_MAGIC_LINK_TOKEN, } from '@fluxer/api/src/donation/tests/DonationTestUtils'; import {createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness'; import {createPwnedPasswordsRangeHandler} from '@fluxer/api/src/test/msw/handlers/PwnedPasswordsHandlers'; import {createStripeApiHandlers} from '@fluxer/api/src/test/msw/handlers/StripeApiHandlers'; import {server} from '@fluxer/api/src/test/msw/server'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest'; describe('GET /donations/manage', () => { let harness: Awaited>; let stripeHandlers: ReturnType; let donationRepository: DonationRepository; beforeAll(async () => { harness = await createApiTestHarness(); donationRepository = new DonationRepository(); stripeHandlers = createStripeApiHandlers(); }); afterAll(async () => { await harness.shutdown(); }); beforeEach(async () => { await harness.reset(); stripeHandlers.reset(); server.use(...stripeHandlers.handlers); }); async function createDonorWithCustomerId(email: string, customerId: string): Promise { await donationRepository.createDonor({ email, stripeCustomerId: customerId, businessName: null, taxId: null, taxIdType: null, stripeSubscriptionId: 'sub_test_1', subscriptionAmountCents: 2500, subscriptionCurrency: 'usd', subscriptionInterval: 'month', subscriptionCurrentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), }); } async function createValidMagicLinkToken(email: string, token: string): Promise { const expiresAt = new Date(Date.now() + 15 * 60 * 1000); const tokenModel = new DonorMagicLinkToken({ token_: token, donor_email: email, expires_at: expiresAt, used_at: null, }); await donationRepository.createMagicLinkToken(tokenModel); } async function createExpiredMagicLinkToken(email: string, token: string): Promise { const expiresAt = new Date(Date.now() - 1000); const tokenModel = new DonorMagicLinkToken({ token_: token, donor_email: email, expires_at: expiresAt, used_at: null, }); await donationRepository.createMagicLinkToken(tokenModel); } async function createUsedMagicLinkToken(email: string, token: string): Promise { const expiresAt = new Date(Date.now() + 15 * 60 * 1000); const usedAt = new Date(Date.now() - 5000); const tokenModel = new DonorMagicLinkToken({ token_: token, donor_email: email, expires_at: expiresAt, used_at: usedAt, }); await donationRepository.createMagicLinkToken(tokenModel); } describe('valid token with customer ID', () => { test('redirects to Stripe billing portal', async () => { const customerId = 'cus_test_valid_123'; await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId); await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN); const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw(); expect(response.status).toBe(302); expect(response.headers.get('location')).toContain('https://billing.stripe.com'); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1); expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId); }); test('marks token as used after validation', async () => { const customerId = 'cus_test_valid_456'; await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId); await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN); await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw(); const token = await donationRepository.findMagicLinkToken(TEST_MAGIC_LINK_TOKEN); expect(token?.isUsed()).toBe(true); }); test('includes return_url in portal session', async () => { const customerId = 'cus_test_return_url'; await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId); await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN); await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw(); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1); const portalSession = stripeHandlers.spies.createdPortalSessions[0]; expect(portalSession?.return_url).toBeDefined(); }); }); describe('valid token without customer ID', () => { test('redirects to donation page when donor has no customer ID', async () => { await donationRepository.createDonor({ email: TEST_DONOR_EMAIL, stripeCustomerId: null, businessName: null, taxId: null, taxIdType: null, stripeSubscriptionId: null, subscriptionAmountCents: null, subscriptionCurrency: null, subscriptionInterval: null, subscriptionCurrentPeriodEnd: null, }); await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN); const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw(); expect(response.status).toBe(302); const location = response.headers.get('location'); expect(location).toContain(Config.endpoints.marketing); expect(location).toContain('/donate'); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0); }); test('redirects to donation page when donor does not exist', async () => { await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN); const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw(); expect(response.status).toBe(302); const location = response.headers.get('location'); expect(location).toContain(Config.endpoints.marketing); expect(location).toContain('/donate'); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0); }); test('marks token as used even when redirecting to donate page', async () => { await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN); await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw(); const token = await donationRepository.findMagicLinkToken(TEST_MAGIC_LINK_TOKEN); expect(token?.isUsed()).toBe(true); }); }); describe('expired token handling', () => { test('rejects expired token', async () => { const customerId = 'cus_test_expired'; await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId); await createExpiredMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN); await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN) .expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED) .execute(); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0); }); test('does not mark expired token as used', async () => { await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test'); await createExpiredMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN); await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN) .expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED) .execute(); const token = await donationRepository.findMagicLinkToken(TEST_MAGIC_LINK_TOKEN); expect(token?.isUsed()).toBe(false); }); test('rejects token expired by exactly 1 millisecond', async () => { const expiresAt = new Date(Date.now() - 1); await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test'); const tokenModel = new DonorMagicLinkToken({ token_: TEST_MAGIC_LINK_TOKEN, donor_email: TEST_DONOR_EMAIL, expires_at: expiresAt, used_at: null, }); await donationRepository.createMagicLinkToken(tokenModel); await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN) .expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED) .execute(); }); }); describe('used token handling', () => { test('rejects already-used token', async () => { const customerId = 'cus_test_used'; await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId); await createUsedMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN); await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN) .expect(400, APIErrorCodes.DONATION_MAGIC_LINK_USED) .execute(); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0); }); test('prevents reuse of token after successful redemption', async () => { const customerId = 'cus_test_reuse'; await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId); await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN); await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw(); await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN) .expect(400, APIErrorCodes.DONATION_MAGIC_LINK_USED) .execute(); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1); }); }); describe('invalid token handling', () => { test('rejects non-existent token', async () => { await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN) .expect(400, APIErrorCodes.DONATION_MAGIC_LINK_INVALID) .execute(); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0); }); test('rejects token with invalid format', async () => { await createDonationManageBuilder(harness, TEST_INVALID_TOKEN).expect(400).execute(); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0); }); test('rejects empty token', async () => { await createDonationManageBuilder(harness, '').expect(400).execute(); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0); }); test('rejects token that is too short', async () => { const shortToken = 'a'.repeat(63); await createDonationManageBuilder(harness, shortToken).expect(400).execute(); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0); }); test('rejects token that is too long', async () => { const longToken = 'a'.repeat(65); await createDonationManageBuilder(harness, longToken).expect(400).execute(); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0); }); test('rejects token with non-hex characters', async () => { const invalidToken = 'g'.repeat(64); await createDonationManageBuilder(harness, invalidToken).expect(400).execute(); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0); }); test('rejects token with special characters', async () => { const specialToken = `${'a'.repeat(62)}@!`; await createDonationManageBuilder(harness, specialToken).expect(400).execute(); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0); }); test('rejects token with whitespace', async () => { const whitespaceToken = `${'a'.repeat(32)} ${'a'.repeat(31)}`; await createDonationManageBuilder(harness, whitespaceToken).expect(400).execute(); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0); }); }); describe('missing token parameter', () => { test('rejects request without token query parameter', async () => { const response = await harness.requestJson({ path: '/donations/manage', method: 'GET', }); expect(response.status).toBe(400); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0); }); }); describe('Stripe portal session creation', () => { test('handles Stripe API failure gracefully', async () => { const customerId = 'cus_test_stripe_fail'; await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId); await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN); server.resetHandlers(); const failingHandlers = createStripeApiHandlers({portalShouldFail: true}); server.use(createPwnedPasswordsRangeHandler(), ...failingHandlers.handlers); const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw(); expect(response.status).toBe(400); }); test('creates portal session with correct customer ID', async () => { const customerId = 'cus_test_specific_123'; await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId); await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN); await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw(); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1); const session = stripeHandlers.spies.createdPortalSessions[0]; expect(session?.customer).toBe(customerId); }); }); describe('edge cases', () => { test('handles token for donor with cancelled subscription', async () => { await donationRepository.createDonor({ email: TEST_DONOR_EMAIL, stripeCustomerId: 'cus_test_cancelled', businessName: null, taxId: null, taxIdType: null, stripeSubscriptionId: null, subscriptionAmountCents: null, subscriptionCurrency: null, subscriptionInterval: null, subscriptionCurrentPeriodEnd: null, }); await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN); const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw(); expect(response.status).toBe(302); expect(response.headers.get('location')).toContain('https://billing.stripe.com'); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1); }); test('handles token expiring in exactly 0 milliseconds', async () => { const expiresAt = new Date(Date.now()); await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test'); const tokenModel = new DonorMagicLinkToken({ token_: TEST_MAGIC_LINK_TOKEN, donor_email: TEST_DONOR_EMAIL, expires_at: expiresAt, used_at: null, }); await donationRepository.createMagicLinkToken(tokenModel); await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN) .expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED) .execute(); }); test('handles case-sensitive token comparison', async () => { const lowerToken = 'a'.repeat(64); const upperToken = 'A'.repeat(64); await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test'); await createValidMagicLinkToken(TEST_DONOR_EMAIL, lowerToken); await createDonationManageBuilder(harness, upperToken) .expect(400, APIErrorCodes.DONATION_MAGIC_LINK_INVALID) .execute(); }); test('handles multiple valid tokens for different donors', async () => { const token1 = 'a'.repeat(64); const token2 = 'b'.repeat(64); const email1 = 'donor1@test.com'; const email2 = 'donor2@test.com'; const customerId1 = 'cus_test_1'; const customerId2 = 'cus_test_2'; await createDonorWithCustomerId(email1, customerId1); await createDonorWithCustomerId(email2, customerId2); await createValidMagicLinkToken(email1, token1); await createValidMagicLinkToken(email2, token2); const {response: response1} = await createDonationManageBuilder(harness, token1).executeRaw(); expect(response1.status).toBe(302); expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId1); const {response: response2} = await createDonationManageBuilder(harness, token2).executeRaw(); expect(response2.status).toBe(302); expect(stripeHandlers.spies.createdPortalSessions[1]?.customer).toBe(customerId2); expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(2); }); }); describe('token validation order', () => { test('checks token existence before expiration', async () => { await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN) .expect(400, APIErrorCodes.DONATION_MAGIC_LINK_INVALID) .execute(); }); test('checks expiration before usage status', async () => { const expiresAt = new Date(Date.now() - 1000); const usedAt = new Date(Date.now() - 5000); await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test'); const tokenModel = new DonorMagicLinkToken({ token_: TEST_MAGIC_LINK_TOKEN, donor_email: TEST_DONOR_EMAIL, expires_at: expiresAt, used_at: usedAt, }); await donationRepository.createMagicLinkToken(tokenModel); await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN) .expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED) .execute(); }); }); });