mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
The guard is `{ id: { in: [] } }`, which is only safe because Prisma
renders an empty IN list as `WHERE 1=0` instead of dropping the filter.
A regression there would delete every workspace on a self-hosted
deployment, which is too expensive to leave resting on that assumption.
154 lines
6.4 KiB
TypeScript
154 lines
6.4 KiB
TypeScript
// Exercises scripts/expired-billing-cleanup.ts against a real database, because the bug this
|
|
// file exists for could not be seen any other way.
|
|
//
|
|
// The filter used to be written as `NOT: buildBillingAccessWhereInput(now)`, which the unit
|
|
// tests happily asserted on: as an object it reads correctly. Prisma renders it as
|
|
// `NOT (status IN (...) OR "trialEndsAt" > $1 OR "stripeCurrentPeriodEnd" > $2)`, and SQL
|
|
// comparisons against NULL are unknown rather than false, so a row with both dates empty
|
|
// evaluates to NOT NULL and is dropped. A canceled subscriber is exactly that row, so the
|
|
// scheduled cleanup deleted nothing at all while reporting success. Only a query against
|
|
// Postgres shows it, which is why these tests live here and not in tests/unit.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { BillingSubscriptionStatus } from '@prisma/client';
|
|
import { db } from '@/lib/db';
|
|
import { cleanupExpiredBillingWorkspaces } from '../../scripts/expired-billing-cleanup';
|
|
import { createUser, createVersion, createVideo, seedProject } from '../factories';
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
/** Every Bunny video id the cleanup asked Bunny to delete, in call order. */
|
|
let bunnyDeletes: string[] = [];
|
|
|
|
beforeEach(() => {
|
|
bunnyDeletes = [];
|
|
vi.stubEnv('BUNNY_STREAM_API_KEY', 'test-bunny-key');
|
|
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '999999');
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async (url: string | URL, init?: { method?: string }) => {
|
|
const href = typeof url === 'string' ? url : url.toString();
|
|
if (init?.method === 'DELETE' && href.includes('video.bunnycdn.com')) {
|
|
bunnyDeletes.push(href.split('/videos/')[1] ?? '');
|
|
return new Response(null, { status: 200 });
|
|
}
|
|
throw new Error(`Unexpected fetch in test: ${init?.method ?? 'GET'} ${href}`);
|
|
})
|
|
);
|
|
});
|
|
|
|
// tests/setup/api.ts already undoes stubbed envs after every test, but not globals, and a
|
|
// leaked fetch stub would swallow the next file's HTTP calls.
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
/**
|
|
* An owner whose access ended `endedDaysAgo` ago, shaped the way Stripe actually leaves the
|
|
* row: the status is CANCELED and both date columns are empty, because
|
|
* markSubscriptionCanceledByCustomerId() clears trialEndsAt and Stripe no longer reports
|
|
* current_period_end on the subscription itself.
|
|
*/
|
|
async function seedCanceledOwner(endedDaysAgo: number) {
|
|
const owner = await createUser({
|
|
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
|
trialEndsAt: null,
|
|
stripeCurrentPeriodEnd: null,
|
|
billingAccessEndedAt: new Date(Date.now() - endedDaysAgo * DAY_MS),
|
|
});
|
|
const { workspace, project } = await seedProject({ ownerUser: owner });
|
|
const video = await createVideo({ projectId: project.id });
|
|
const version = await createVersion({
|
|
videoParentId: video.id,
|
|
providerId: 'bunny',
|
|
providerVideoId: `bunny-video-${workspace.id.slice(0, 12)}`,
|
|
});
|
|
|
|
return { owner, workspace, project, video, version };
|
|
}
|
|
|
|
describe('cleanupExpiredBillingWorkspaces', () => {
|
|
it('deletes the workspace of a canceled owner whose trial and period columns are null', async () => {
|
|
const { workspace, version } = await seedCanceledOwner(30);
|
|
|
|
const result = await cleanupExpiredBillingWorkspaces();
|
|
|
|
expect(result).toEqual({ owners: 1, scanned: 1, deleted: 1 });
|
|
expect(await db.workspace.findUnique({ where: { id: workspace.id } })).toBeNull();
|
|
expect(bunnyDeletes).toEqual([version.videoId]);
|
|
});
|
|
|
|
it('leaves an owner inside the fifteen day grace period alone', async () => {
|
|
const { workspace } = await seedCanceledOwner(5);
|
|
|
|
const result = await cleanupExpiredBillingWorkspaces();
|
|
|
|
expect(result).toEqual({ owners: 0, scanned: 0, deleted: 0 });
|
|
expect(await db.workspace.findUnique({ where: { id: workspace.id } })).not.toBeNull();
|
|
expect(bunnyDeletes).toEqual([]);
|
|
});
|
|
|
|
it('leaves an owner with billing access alone', async () => {
|
|
const { workspace } = await seedProject();
|
|
|
|
const result = await cleanupExpiredBillingWorkspaces();
|
|
|
|
expect(result).toEqual({ owners: 0, scanned: 0, deleted: 0 });
|
|
expect(await db.workspace.findUnique({ where: { id: workspace.id } })).not.toBeNull();
|
|
});
|
|
|
|
it('collects an owner whose trial lapsed without Stripe ever setting an end date', async () => {
|
|
const owner = await createUser({
|
|
subscriptionStatus: BillingSubscriptionStatus.FREE,
|
|
trialEndsAt: new Date(Date.now() - 30 * DAY_MS),
|
|
billingAccessEndedAt: null,
|
|
});
|
|
const { workspace } = await seedProject({ ownerUser: owner });
|
|
|
|
const result = await cleanupExpiredBillingWorkspaces();
|
|
|
|
expect(result).toEqual({ owners: 1, scanned: 1, deleted: 1 });
|
|
expect(await db.workspace.findUnique({ where: { id: workspace.id } })).toBeNull();
|
|
});
|
|
|
|
it('reports what it would delete without touching anything on a dry run', async () => {
|
|
const { workspace } = await seedCanceledOwner(30);
|
|
|
|
const result = await cleanupExpiredBillingWorkspaces({ dryRun: true });
|
|
|
|
expect(result).toEqual({ owners: 1, scanned: 1, deleted: 0 });
|
|
expect(await db.workspace.findUnique({ where: { id: workspace.id } })).not.toBeNull();
|
|
expect(bunnyDeletes).toEqual([]);
|
|
});
|
|
|
|
// The guard is `{ id: { in: [] } }`, and an empty IN list is only safe if Prisma renders it
|
|
// as a contradiction rather than dropping the filter. It renders `WHERE 1=0`, but a
|
|
// regression there would delete every workspace on a self-hosted deployment, so it is worth
|
|
// a test rather than trust.
|
|
it('deletes nothing when Stripe is disabled, since nothing can expire without billing', async () => {
|
|
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
|
const { workspace } = await seedCanceledOwner(30);
|
|
|
|
const result = await cleanupExpiredBillingWorkspaces();
|
|
|
|
expect(result).toEqual({ owners: 0, scanned: 0, deleted: 0 });
|
|
expect(await db.workspace.findUnique({ where: { id: workspace.id } })).not.toBeNull();
|
|
expect(bunnyDeletes).toEqual([]);
|
|
});
|
|
|
|
it('counts an expired owner who owns no workspace, so an empty scan is not mistaken for an empty user table', async () => {
|
|
await createUser({
|
|
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
|
trialEndsAt: null,
|
|
stripeCurrentPeriodEnd: null,
|
|
billingAccessEndedAt: new Date(Date.now() - 30 * DAY_MS),
|
|
});
|
|
|
|
expect(await cleanupExpiredBillingWorkspaces()).toEqual({
|
|
owners: 1,
|
|
scanned: 0,
|
|
deleted: 0,
|
|
});
|
|
});
|
|
});
|