/*
* 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();
});
});
});