Files
OpenFrame/tests/api/lib-admin-stats.test.ts
T
yusufipk b51e690062 fix: close the findings the test suite surfaced
The suite that landed in #43/#44 was written against existing behaviour, so a
number of tests pinned bugs rather than asserting correct behaviour. This fixes
the production code and moves each of those tests onto the fixed behaviour in
the same change.

Security:

- project-download: derive the archive entry extension from the last path
  segment and restrict it to a short alphanumeric run, so an extensionless
  allowlisted url can no longer contribute a path separator; validate the r2
  branch against the strict proxy-path pattern instead of a `startsWith`, which
  let `/api/upload/video/clip.mp4/../../etc/passwd` through verbatim.
- rate-limit: hash a key or action wider than its column instead of skipping the
  query. Both the guard and the failing INSERT used to answer "allowed", so the
  limit stopped applying entirely. Warn at startup when TRUSTED_PROXY_MODE is
  unset in production.
- video uploads: the file name decides the content type; a client-declared video
  mime no longer makes `payload.exe` acceptable.
- email templates: escape in the helpers rather than relying on every caller,
  with an explicit `rawEmailHtml()` opt-out for the one call site that builds
  markup. `escapeHtml` now covers the single quote.
- CSP: allow loopback object storage outside production only.
- route-access: reach the billing redirect only for the workspace owner. Keying
  it off the owner's billing status alone made the redirect target an oracle for
  whose subscription had lapsed, and sent members to a page they cannot act on.
- search: carry the same billing condition every other read path carries.
- logger: check `err.name` as well as `err.constructor.name`, so a re-thrown,
  deserialised or minified Prisma error is still redacted.
- upload tokens: resolve the signing secret outside the try, so a server booted
  without one fails loudly instead of reporting every grant as a forgery.
- invitations: never downgrade an existing membership, and report a scoped
  invitation that points at nothing as not_found rather than accepted.
- auth: resolve the workspace role for every signed-in caller, so
  checkProjectAccess and computeProjectAccess stop disagreeing about the owner
  who also owns the workspace. The `intent` option is gone with it.
- r2-media-proxy: validate the object key inside the proxy so the guard travels
  with the function; delete the unused, unanchored `mediaUrlToR2Key`.
- r2: sign the content type into presigned PUT grants.

Correctness:

- frame rate snapping picks the nearest standard, not the first within
  tolerance, so 24, 30 and 60 fps are reachable at all.
- a version upload registers its Bunny cleanup as soon as bunny-init answers, so
  a failed tus upload no longer leaves a billed video behind.
- deleting videos clears storage before the rows, so a refused DELETE leaves a
  retryable row rather than an orphaned object.
- an expired upload session can be cancelled, which is what releases its quota.
- `voice/` joins the delete allowlist, so a voice note can be removed by the
  module that wrote it.
- a failed CORS write propagates instead of being mistaken for an empty config
  and replacing the bucket's rules.
- filtering projects by workspace no longer hides projects the unfiltered call
  returns.
- upload retries skip aborts and permanent 4xx; progress no longer divides by
  zero.
- reply edits no longer clear the comment's tag; optimistic resolve rolls back
  to the state it replaced; the delete snapshot is captured once.
- assorted UI fixes: duplicate React keys, double-click guards reading stale
  closures, the tag list fetched twice per load, a failed member list rendering
  as an empty one, a stale "Initializing upload..." beside a failure, and a
  registration banner pointing at an email that never arrives.

Consistency and access:

- the two download routes answer 404 for an id belonging to another tenant, as
  the comment export route already did. A caller who does belong still gets 403.
- accessible names for the share-link password field, the guest name gates, the
  version dialog inputs and the comment-tag controls.

Repository health:

- the runner image installs production dependencies only.
- a setup file for the unit project restores stubbed env centrally.
- native tsconfig path resolution replaces vite-tsconfig-paths.
- `uploadBytesWithProgress` exists once.
- admin stats bill Bunny storage to the workspace owner like every other
  quota, gate on the configured flag, wire up the single-flight guard and count
  the statuses that belonged to no bucket.
- `r2Client.destroy()` releases the presign client too.
- `prepare` tolerates a production install, where husky is absent.
2026-07-26 18:53:54 +07:00

990 lines
35 KiB
TypeScript

// Exercises lib/admin-stats.ts, the aggregation behind the two admin
// dashboards, against real Postgres.
//
// Two things make the module worth a suite of its own rather than coverage
// through the pages that render it. First, `getCachedUserBunnyStorage` is not
// only a dashboard number: lib/storage-quota.ts adds its answer to a user's
// used bytes before granting an upload, so a grouping mistake here refuses or
// grants real uploads. Second, every figure is a byte count, and the
// download-egress path carries a BigInt column into a JavaScript number, which
// is exactly the seam this repo has already been bitten on.
//
// Only the boundaries are faked: `r2Client` (ListObjectsV2), global `fetch`
// (the Bunny API) and `getStripe()`. Every row the module aggregates over is a
// real row in the test database, because the aggregation is the thing under
// test.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// tests/setup/api.ts replaces `getCachedUserBunnyStorage` with a vi.fn() that
// answers `{}`, because it sits in the middle of reserveStorageQuota() and
// would otherwise reach the Bunny API from every upload test. This file is the
// one place that has to run the real implementation, so it drops that
// registration entirely. `vi.unmock` is hoisted like `vi.mock`, so it lands
// after the setup file's registration and wins. `mockRestore()` would not do
// the job: the stub is a bare vi.fn(), not a spy over the real export, so
// restoring it yields a function that returns undefined.
vi.unmock('@/lib/admin-stats');
import { DownloadEgressSource, VideoAssetKind, VideoAssetProvider } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe';
import {
getCachedBunnyStorageStats,
getCachedStripeStats,
getCachedTotalStorage,
getCachedUserBunnyStorage,
getCachedUserDownloadEgress,
getCachedUserMediaStorage,
refreshR2StorageSnapshot,
} from '@/lib/admin-stats';
import {
createComment,
createProject,
createUser,
createVersion,
createVideo,
createVideoAsset,
createWorkspace,
nextSeq,
seedProject,
seedVersion,
} from '../factories';
// ---------------------------------------------------------------------------
// R2 boundary
// ---------------------------------------------------------------------------
// vi.mock factories are hoisted above every const in the file, so the recorder
// has to be hoisted with them. The setup file stubs the named helpers in
// `@/lib/r2` but not `r2Client`, which is what listAllR2FileSizes() reaches
// for.
const r2 = vi.hoisted(() => ({
bucket: 'openframe-admin-stats-test-bucket',
/** Successive ListObjectsV2 responses, one consumed per send(). */
pages: [] as Array<{
Contents?: Array<{ Key?: string; Size?: number }>;
IsTruncated?: boolean;
NextContinuationToken?: string;
}>,
/** The continuation token on each send(), in call order. */
requestedTokens: [] as Array<string | undefined>,
/** The bucket on each send(), in call order. */
requestedBuckets: [] as string[],
/** Set to make the next send() reject, for the storage-unreachable path. */
failure: null as Error | null,
}));
vi.mock('@/lib/r2', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/r2')>();
return {
...actual,
R2_BUCKET_NAME: r2.bucket,
r2Client: {
send: async (command: { input?: { Bucket?: string; ContinuationToken?: string } }) => {
if (r2.failure) throw r2.failure;
r2.requestedBuckets.push(command.input?.Bucket ?? '');
r2.requestedTokens.push(command.input?.ContinuationToken);
return r2.pages[r2.requestedTokens.length - 1] ?? { Contents: [], IsTruncated: false };
},
},
};
});
// The snapshot lives on globalThis so it survives a Next.js server render, which
// also means it survives from one test to the next. Clear it, or a test that
// expects "no snapshot yet" passes or fails on file order.
const adminGlobals = globalThis as unknown as {
adminR2StorageSnapshot?: unknown;
adminR2StorageSnapshotPromise?: unknown;
};
beforeEach(() => {
delete adminGlobals.adminR2StorageSnapshot;
delete adminGlobals.adminR2StorageSnapshotPromise;
r2.pages = [];
r2.requestedTokens.length = 0;
r2.requestedBuckets.length = 0;
r2.failure = null;
// Every degrade path in this module logs through logError(). Keep the suite
// output readable without hiding a genuine failure.
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/**
* Takes an R2 snapshot holding exactly these keys and sizes.
*
* The recorders are cleared first because the fake client picks its response by
* call count, so a second snapshot in the same test would otherwise run off the
* end of the script and list an empty bucket.
*/
async function snapshotBucket(fileSizes: Record<string, number>): Promise<string> {
r2.requestedTokens.length = 0;
r2.requestedBuckets.length = 0;
r2.pages = [
{
Contents: Object.entries(fileSizes).map(([Key, Size]) => ({ Key, Size })),
IsTruncated: false,
},
];
return refreshR2StorageSnapshot();
}
// ---------------------------------------------------------------------------
// Bunny boundary
// ---------------------------------------------------------------------------
interface BunnyPage {
status?: number;
body?: unknown;
}
interface BunnyCalls {
urls: string[];
accessKeys: Array<string | undefined>;
}
/** Answers the Bunny video-list endpoint with these pages, in order. */
function stubBunnyPages(pages: BunnyPage[]): BunnyCalls {
const calls: BunnyCalls = { urls: [], accessKeys: [] };
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown, init?: { headers?: Record<string, string> }) => {
calls.urls.push(String(input));
calls.accessKeys.push(init?.headers?.AccessKey);
// Past the end of the script, answer an empty page so a runaway loop
// terminates instead of hanging the suite.
const page = pages[calls.urls.length - 1] ?? { body: { items: [] } };
const status = page.status ?? 200;
return {
ok: status >= 200 && status < 300,
status,
json: async () => page.body,
};
})
);
return calls;
}
/** Credentials plus a one-page library holding exactly these videos. */
function stubBunnyLibrary(sizes: Record<string, number>): BunnyCalls {
vi.stubEnv('BUNNY_STREAM_API_KEY', 'bunny-key-for-admin-stats');
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '4242');
const items = Object.entries(sizes).map(([guid, storageSize]) => ({ guid, storageSize }));
return stubBunnyPages([{ body: { items, totalItems: items.length } }]);
}
function bunnyCredentials(): void {
vi.stubEnv('BUNNY_STREAM_API_KEY', 'bunny-key-for-admin-stats');
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '4242');
}
// ---------------------------------------------------------------------------
// Download egress
// ---------------------------------------------------------------------------
// DownloadEgressEvent carries no foreign keys (see prisma/schema.prisma), so
// the ids other than billedUserId can be synthetic. There is no factory for it
// and tests/factories is shared, so the builder lives here.
async function recordDownload(billedUserId: string, estimatedBytes: bigint): Promise<void> {
const seq = nextSeq();
await db.downloadEgressEvent.create({
data: {
versionId: `egress-version-${seq}`,
videoId: `egress-video-${seq}`,
projectId: `egress-project-${seq}`,
workspaceId: `egress-workspace-${seq}`,
billedUserId,
source: DownloadEgressSource.ORIGINAL,
estimatedBytes,
},
});
}
// ---------------------------------------------------------------------------
// Stripe boundary
// ---------------------------------------------------------------------------
/** Installs a Stripe double whose price lookup answers this price. */
function stubStripePrice(price: { unit_amount?: number | null; currency?: string }): {
retrievedPriceIds: string[];
} {
const retrievedPriceIds: string[] = [];
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
prices: {
retrieve: vi.fn(async (priceId: string) => {
retrievedPriceIds.push(priceId);
return price;
}),
},
});
return { retrievedPriceIds };
}
describe('refreshR2StorageSnapshot', () => {
it('walks every page of the bucket listing and totals the object sizes', async () => {
r2.pages = [
{
Contents: [
{ Key: 'voice/one.webm', Size: 100 },
{ Key: 'images/two.png', Size: 250 },
],
IsTruncated: true,
NextContinuationToken: 'page-two',
},
{
Contents: [{ Key: 'videos/three.mp4', Size: 1000 }],
IsTruncated: false,
},
];
await refreshR2StorageSnapshot();
expect(await getCachedTotalStorage()).toBe(1350);
// The second request must carry the token the first one handed back, or the
// listing silently stops at 1000 objects and every total is wrong.
expect(r2.requestedTokens).toEqual([undefined, 'page-two']);
expect(r2.requestedBuckets).toEqual([r2.bucket, r2.bucket]);
});
it('counts an object with no reported size as zero and skips one with no key', async () => {
r2.pages = [
{
Contents: [
{ Key: 'voice/sizeless.webm' },
{ Key: 'images/known.png', Size: 40 },
{ Size: 999 },
],
IsTruncated: false,
},
];
await refreshR2StorageSnapshot();
expect(await getCachedTotalStorage()).toBe(40);
});
it('reports an empty bucket as zero bytes rather than as unavailable', async () => {
await refreshR2StorageSnapshot();
expect(await getCachedTotalStorage()).toBe(0);
});
it('answers the moment of the refresh as an ISO timestamp', async () => {
const before = Date.now();
const refreshedAt = await refreshR2StorageSnapshot();
expect(refreshedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(Date.parse(refreshedAt)).toBeGreaterThanOrEqual(before);
expect(Date.parse(refreshedAt)).toBeLessThanOrEqual(Date.now());
});
it('propagates a storage failure to the caller instead of storing a partial snapshot', async () => {
await snapshotBucket({ 'images/kept.png': 77 });
r2.failure = new Error('storage is unreachable');
await expect(refreshR2StorageSnapshot()).rejects.toThrow('storage is unreachable');
// The previous snapshot survives, so the dashboard keeps showing the last
// known figure rather than dropping to the -1 sentinel.
r2.failure = null;
expect(await getCachedTotalStorage()).toBe(77);
});
});
describe('getCachedTotalStorage', () => {
it('answers -1 when no snapshot has been taken yet', async () => {
expect(await getCachedTotalStorage()).toBe(-1);
});
it('never touches storage on its own, so the sentinel is not a listing failure', async () => {
await getCachedTotalStorage();
expect(r2.requestedTokens).toEqual([]);
});
it('answers the byte total of the most recent snapshot', async () => {
await snapshotBucket({ 'images/a.png': 5 });
expect(await getCachedTotalStorage()).toBe(5);
await snapshotBucket({ 'images/a.png': 5, 'voice/b.webm': 15 });
expect(await getCachedTotalStorage()).toBe(20);
});
});
describe('getCachedBunnyStorageStats', () => {
it('totals the library and indexes each video by its guid', async () => {
const calls = stubBunnyLibrary({ 'bunny-one': 500, 'bunny-two': 250 });
const stats = await getCachedBunnyStorageStats();
expect(stats).toEqual({ totalBytes: 750, byVideoId: { 'bunny-one': 500, 'bunny-two': 250 } });
expect(calls.urls).toHaveLength(1);
expect(calls.urls[0]).toBe(
'https://video.bunnycdn.com/library/4242/videos?page=1&itemsPerPage=100'
);
expect(calls.accessKeys).toEqual(['bunny-key-for-admin-stats']);
});
it('stops requesting pages once the reported item count is covered', async () => {
bunnyCredentials();
const calls = stubBunnyPages([
{ body: { items: [{ guid: 'bunny-one', storageSize: 10 }], totalItems: 1 } },
]);
const stats = await getCachedBunnyStorageStats();
expect(stats.totalBytes).toBe(10);
expect(calls.urls).toHaveLength(1);
});
it('keeps paging when the API reports no total and stops at the first empty page', async () => {
bunnyCredentials();
const calls = stubBunnyPages([
{ body: { items: [{ guid: 'bunny-one', storageSize: 10 }] } },
{ body: { items: [{ guid: 'bunny-two', storageSize: 32 }] } },
{ body: { items: [] } },
]);
const stats = await getCachedBunnyStorageStats();
expect(stats).toEqual({ totalBytes: 42, byVideoId: { 'bunny-one': 10, 'bunny-two': 32 } });
expect(calls.urls).toHaveLength(3);
expect(calls.urls[1]).toContain('page=2');
expect(calls.urls[2]).toContain('page=3');
});
it('reads the capitalised item list Bunny sometimes returns', async () => {
bunnyCredentials();
stubBunnyPages([{ body: { Items: [{ guid: 'bunny-one', storageSize: 64 }], TotalItems: 1 } }]);
expect(await getCachedBunnyStorageStats()).toEqual({
totalBytes: 64,
byVideoId: { 'bunny-one': 64 },
});
});
it('accepts any of the three size field names and treats an absent one as zero', async () => {
bunnyCredentials();
stubBunnyPages([
{
body: {
items: [
{ guid: 'by-storage-size', storageSize: 5 },
{ guid: 'by-storage', storage: 7 },
{ guid: 'by-size', size: 11 },
{ guid: 'no-size-at-all' },
{ guid: 'negative-size', size: -400 },
],
totalItems: 5,
},
},
]);
expect(await getCachedBunnyStorageStats()).toEqual({
totalBytes: 23,
byVideoId: {
'by-storage-size': 5,
'by-storage': 7,
'by-size': 11,
'no-size-at-all': 0,
'negative-size': 0,
},
});
});
it('skips an item with no guid rather than indexing it under an empty key', async () => {
bunnyCredentials();
stubBunnyPages([
{
body: {
items: [{ storageSize: 900 }, { guid: 'bunny-one', storageSize: 3 }],
totalItems: 2,
},
},
]);
expect(await getCachedBunnyStorageStats()).toEqual({
totalBytes: 3,
byVideoId: { 'bunny-one': 3 },
});
});
// -1 rather than 0 is the whole point: 0 would tell the dashboard that Bunny
// holds nothing, and would tell getCachedUserBunnyStorage to charge every
// user zero bytes.
it('degrades to -1 instead of throwing when the Bunny API answers an error', async () => {
bunnyCredentials();
stubBunnyPages([{ status: 500, body: {} }]);
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: -1, byVideoId: {} });
});
it('degrades to -1 instead of throwing when fetch itself fails', async () => {
bunnyCredentials();
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('network timeout');
})
);
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: -1, byVideoId: {} });
});
// The flag defaults to on, so a self-hosted deployment that never configured Bunny
// lands here: credentials missing while the feature is nominally enabled. That used to
// key on the flag alone, throw "Missing Bunny Stream credentials." out of
// getBunnyConfig() and report -1, which reads as "we could not measure" rather than
// "there is nothing to measure".
it('reports a genuine zero when the Bunny credentials are not configured', async () => {
vi.stubEnv('BUNNY_STREAM_API_KEY', undefined);
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', undefined);
vi.stubEnv('NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID', undefined);
const calls = stubBunnyPages([]);
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: 0, byVideoId: {} });
expect(calls.urls).toEqual([]);
});
it('reports a genuine zero without calling Bunny when the feature flag is off', async () => {
vi.stubEnv('OPENFRAME_ENABLE_BUNNY_UPLOADS', 'false');
const calls = stubBunnyLibrary({ 'bunny-one': 500 });
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: 0, byVideoId: {} });
expect(calls.urls).toEqual([]);
});
it('falls back to the public library id when the server-side one is unset', async () => {
vi.stubEnv('BUNNY_STREAM_API_KEY', 'bunny-key-for-admin-stats');
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', undefined);
vi.stubEnv('NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID', '7171');
const calls = stubBunnyPages([{ body: { items: [], totalItems: 0 } }]);
await getCachedBunnyStorageStats();
expect(calls.urls[0]).toContain('/library/7171/videos');
});
});
// This block is the reason the file unmocks `@/lib/admin-stats`: everywhere
// else in the api project these calls answer `{}`.
describe('getCachedUserBunnyStorage', () => {
it('attributes each Bunny video to the owner of the project it lives in', async () => {
const first = await seedProject();
const second = await seedProject();
const firstVideo = await createVideo({ projectId: first.project.id });
const secondVideo = await createVideo({ projectId: second.project.id });
await createVersion({
videoParentId: firstVideo.id,
providerId: 'bunny',
providerVideoId: 'bunny-first',
});
await createVersion({
videoParentId: secondVideo.id,
providerId: 'bunny',
providerVideoId: 'bunny-second',
});
stubBunnyLibrary({ 'bunny-first': 500, 'bunny-second': 900 });
const perUser = await getCachedUserBunnyStorage();
expect(perUser).toEqual({ [first.owner.id]: 500, [second.owner.id]: 900 });
});
it('adds up several Bunny videos owned by the same user', async () => {
const scenario = await seedProject();
const firstVideo = await createVideo({ projectId: scenario.project.id });
const secondVideo = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: firstVideo.id,
providerId: 'bunny',
providerVideoId: 'bunny-first',
});
await createVersion({
videoParentId: secondVideo.id,
providerId: 'bunny',
providerVideoId: 'bunny-second',
});
stubBunnyLibrary({ 'bunny-first': 500, 'bunny-second': 900 });
expect(await getCachedUserBunnyStorage()).toEqual({ [scenario.owner.id]: 1400 });
});
it('bills a Bunny asset to the user it records as the billed user', async () => {
const scenario = await seedProject();
const uploader = await createUser();
const video = await createVideo({ projectId: scenario.project.id });
await createVideoAsset({
videoId: video.id,
billedUserId: uploader.id,
kind: VideoAssetKind.VIDEO,
provider: VideoAssetProvider.BUNNY,
providerVideoId: 'bunny-asset',
});
stubBunnyLibrary({ 'bunny-asset': 1234 });
const perUser = await getCachedUserBunnyStorage();
expect(perUser).toEqual({ [uploader.id]: 1234 });
expect(perUser[scenario.owner.id]).toBeUndefined();
});
// Two versions of the same Bunny video (a relabelled upload, say) are one
// object in the library, so charging the owner twice would inflate the number
// that lib/storage-quota.ts checks an upload against.
it('counts a Bunny video once per user however many rows point at it', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
versionNumber: 1,
providerId: 'bunny',
providerVideoId: 'bunny-shared',
});
await createVersion({
videoParentId: video.id,
versionNumber: 2,
providerId: 'bunny',
providerVideoId: 'bunny-shared',
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
kind: VideoAssetKind.VIDEO,
provider: VideoAssetProvider.BUNNY,
providerVideoId: 'bunny-shared',
});
stubBunnyLibrary({ 'bunny-shared': 700 });
expect(await getCachedUserBunnyStorage()).toEqual({ [scenario.owner.id]: 700 });
});
it('ignores versions and assets that are not on Bunny', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
providerId: 'r2',
providerVideoId: 'bunny-first',
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
provider: VideoAssetProvider.R2_IMAGE,
});
stubBunnyLibrary({ 'bunny-first': 500 });
expect(await getCachedUserBunnyStorage()).toEqual({});
});
// An orphan: the row still names a Bunny video that the library no longer
// holds. It has to resolve to zero bytes rather than to undefined, or the
// arithmetic downstream turns into NaN.
it('charges zero for a Bunny video the library no longer knows about', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
providerId: 'bunny',
providerVideoId: 'bunny-deleted-upstream',
});
stubBunnyLibrary({ 'bunny-still-there': 4096 });
const perUser = await getCachedUserBunnyStorage();
expect(perUser).toEqual({ [scenario.owner.id]: 0 });
expect(Number.isNaN(perUser[scenario.owner.id])).toBe(false);
});
// The -1 sentinel means "Bunny did not answer", and the module must not turn
// that into "this user stores nothing", because lib/storage-quota.ts would
// then hand out headroom the user does not have. An empty map at least leaves
// the R2 figures intact.
it('answers an empty map when the Bunny library could not be read', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVersion({
videoParentId: video.id,
providerId: 'bunny',
providerVideoId: 'bunny-first',
});
bunnyCredentials();
stubBunnyPages([{ status: 503, body: {} }]);
expect(await getCachedUserBunnyStorage()).toEqual({});
});
it('is empty on a database with no videos at all', async () => {
stubBunnyLibrary({ 'bunny-orphan': 999 });
expect(await getCachedUserBunnyStorage()).toEqual({});
});
});
describe('getCachedUserMediaStorage', () => {
it('splits a user comment media into voice and image and totals both', async () => {
const scenario = await seedVersion();
await createComment({
versionId: scenario.version.id,
voiceUrl: '/api/upload/audio/note.webm',
imageUrl: '/api/upload/image/shot.png',
});
await snapshotBucket({ 'voice/note.webm': 300, 'images/shot.png': 700 });
expect(await getCachedUserMediaStorage()).toEqual({
[scenario.owner.id]: { total: 1000, voice: 300, image: 700 },
});
});
// Comment media is billed through the workspace owner, matching the join in
// lib/storage-quota.ts, not through the owner of the project the comment
// happens to sit in.
it('bills comment media to the workspace owner rather than the project owner', async () => {
const workspaceOwner = await createUser();
const projectOwner = await createUser();
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
const project = await createProject({
ownerId: projectOwner.id,
workspaceId: workspace.id,
});
const video = await createVideo({ projectId: project.id });
const version = await createVersion({ videoParentId: video.id });
await createComment({ versionId: version.id, voiceUrl: '/api/upload/audio/note.webm' });
await snapshotBucket({ 'voice/note.webm': 120 });
const perUser = await getCachedUserMediaStorage();
expect(perUser).toEqual({ [workspaceOwner.id]: { total: 120, voice: 120, image: 0 } });
expect(perUser[projectOwner.id]).toBeUndefined();
});
it('adds R2 image and audio assets to the user they are billed to', async () => {
const scenario = await seedProject();
const uploader = await createUser();
const video = await createVideo({ projectId: scenario.project.id });
await createVideoAsset({
videoId: video.id,
billedUserId: uploader.id,
provider: VideoAssetProvider.R2_IMAGE,
sourceUrl: '/api/upload/image/asset.png',
});
await createVideoAsset({
videoId: video.id,
billedUserId: uploader.id,
kind: VideoAssetKind.AUDIO,
provider: VideoAssetProvider.R2_AUDIO,
sourceUrl: '/api/upload/audio/asset.webm',
});
await snapshotBucket({ 'images/asset.png': 11, 'voice/asset.webm': 22 });
expect(await getCachedUserMediaStorage()).toEqual({
[uploader.id]: { total: 33, voice: 22, image: 11 },
});
});
it('leaves video and Bunny assets out, since they are not comment media', async () => {
const scenario = await seedProject();
const video = await createVideo({ projectId: scenario.project.id });
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
kind: VideoAssetKind.VIDEO,
provider: VideoAssetProvider.R2_VIDEO,
sourceUrl: '/api/upload/video/movie.mp4',
});
await createVideoAsset({
videoId: video.id,
billedUserId: scenario.owner.id,
kind: VideoAssetKind.VIDEO,
provider: VideoAssetProvider.BUNNY,
providerVideoId: 'bunny-one',
sourceUrl: '/api/upload/video/other.mp4',
});
await snapshotBucket({ 'images/movie.mp4': 5000, 'voice/other.mp4': 5000 });
expect(await getCachedUserMediaStorage()).toEqual({});
});
// The same object reached through both a comment row and an asset row is one
// object in the bucket, so it must be charged once.
it('counts an object once when both a comment and an asset point at it', async () => {
const scenario = await seedVersion();
await createComment({
versionId: scenario.version.id,
imageUrl: '/api/upload/image/same.png',
});
await createVideoAsset({
videoId: scenario.video.id,
billedUserId: scenario.owner.id,
provider: VideoAssetProvider.R2_IMAGE,
sourceUrl: '/api/upload/image/same.png',
});
await snapshotBucket({ 'images/same.png': 640 });
expect(await getCachedUserMediaStorage()).toEqual({
[scenario.owner.id]: { total: 640, voice: 0, image: 640 },
});
});
it('keeps one user media out of another user total', async () => {
const first = await seedVersion();
const second = await seedVersion();
await createComment({
versionId: first.version.id,
imageUrl: '/api/upload/image/first.png',
});
await createComment({
versionId: second.version.id,
imageUrl: '/api/upload/image/second.png',
});
await snapshotBucket({ 'images/first.png': 100, 'images/second.png': 900 });
expect(await getCachedUserMediaStorage()).toEqual({
[first.owner.id]: { total: 100, voice: 0, image: 100 },
[second.owner.id]: { total: 900, voice: 0, image: 900 },
});
});
// An orphaned row: the comment still names a file that is no longer in the
// bucket. The user must still appear, at zero, rather than vanish or count
// NaN bytes.
it('charges zero for a comment whose file is no longer in the bucket', async () => {
const scenario = await seedVersion();
await createComment({
versionId: scenario.version.id,
voiceUrl: '/api/upload/audio/deleted.webm',
});
await snapshotBucket({ 'images/unrelated.png': 4096 });
expect(await getCachedUserMediaStorage()).toEqual({
[scenario.owner.id]: { total: 0, voice: 0, image: 0 },
});
});
it('ignores comments that carry no media at all', async () => {
const scenario = await seedVersion();
await createComment({ versionId: scenario.version.id, content: 'just text' });
await snapshotBucket({ 'images/unrelated.png': 4096 });
expect(await getCachedUserMediaStorage()).toEqual({});
});
it('is empty on an empty database', async () => {
await snapshotBucket({ 'images/orphan.png': 4096 });
expect(await getCachedUserMediaStorage()).toEqual({});
});
// Without a snapshot the module cannot size anything, and it answers an empty
// map rather than a map full of zeros. The difference matters: zeros would
// read on the dashboard as "this user stores nothing".
it('answers an empty map when no R2 snapshot has been taken', async () => {
const scenario = await seedVersion();
await createComment({
versionId: scenario.version.id,
imageUrl: '/api/upload/image/shot.png',
});
expect(await getCachedUserMediaStorage()).toEqual({});
});
});
describe('getCachedUserDownloadEgress', () => {
it('sums the estimated bytes of every download billed to a user', async () => {
const first = await createUser();
const second = await createUser();
await recordDownload(first.id, BigInt(1000));
await recordDownload(first.id, BigInt(2500));
await recordDownload(second.id, BigInt(7));
expect(await getCachedUserDownloadEgress()).toEqual({
[first.id]: 3500,
[second.id]: 7,
});
});
it('is empty when nothing has been downloaded', async () => {
await createUser();
expect(await getCachedUserDownloadEgress()).toEqual({});
});
it('reports zero for a user whose downloads all measured zero bytes', async () => {
const user = await createUser();
await recordDownload(user.id, BigInt(0));
expect(await getCachedUserDownloadEgress()).toEqual({ [user.id]: 0 });
});
// estimatedBytes is a BigInt column and the dashboard wants a number. A
// 9 PB total is not realistic, but the conversion is the same one every row
// goes through, and the answer must be bytes rather than any other unit.
it('returns the sum in bytes, not in any larger unit', async () => {
const user = await createUser();
await recordDownload(user.id, BigInt(5) * BigInt(1024) * BigInt(1024));
expect(await getCachedUserDownloadEgress()).toEqual({ [user.id]: 5_242_880 });
});
it('carries a sum that fits in a double across exactly', async () => {
const user = await createUser();
await recordDownload(user.id, BigInt(Number.MAX_SAFE_INTEGER));
expect(await getCachedUserDownloadEgress()).toEqual({ [user.id]: 9_007_199_254_740_991 });
});
// Past 2^53 a Number() cast silently rounds. The module clamps instead, so an
// absurd total reads as "at the ceiling" rather than as a quietly wrong
// figure.
it('clamps a sum beyond the safe integer range instead of rounding it', async () => {
const user = await createUser();
await recordDownload(user.id, BigInt(Number.MAX_SAFE_INTEGER));
await recordDownload(user.id, BigInt(1000));
expect(await getCachedUserDownloadEgress()).toEqual({ [user.id]: Number.MAX_SAFE_INTEGER });
});
});
describe('getCachedStripeStats', () => {
beforeEach(() => {
// getStripe() is mocked in tests/setup/api.ts and its implementation is
// module state, so it survives afterEach. Reset it so no test inherits
// another test's price.
vi.mocked(getStripe as unknown as () => unknown).mockReset();
vi.stubEnv('STRIPE_PRICE_ID', 'price_admin_stats_test');
});
it('counts users by subscription status and prices the active ones', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
await createUser({ subscriptionStatus: 'ACTIVE' });
await createUser({ subscriptionStatus: 'TRIALING' });
await createUser({ subscriptionStatus: 'PAST_DUE' });
await createUser({ subscriptionStatus: 'CANCELED' });
await createUser({ subscriptionStatus: 'FREE' });
await createUser({ subscriptionStatus: 'FREE' });
await createUser({ subscriptionStatus: 'FREE' });
const stripe = stubStripePrice({ unit_amount: 1900, currency: 'eur' });
expect(await getCachedStripeStats()).toEqual({
activeSubscribers: 2,
trialingUsers: 1,
pastDueUsers: 1,
canceledUsers: 1,
freeUsers: 3,
otherStatusUsers: 0,
mrrCents: 3800,
currency: 'eur',
});
expect(stripe.retrievedPriceIds).toEqual(['price_admin_stats_test']);
});
// UNPAID, INCOMPLETE and INCOMPLETE_EXPIRED are real values of the enum that used to
// belong to none of the reported buckets, so those users were counted nowhere and the
// five totals silently did not add up to the user table. They must not be folded into
// one of the five either.
it('counts the statuses the five named buckets do not cover', async () => {
await createUser({ subscriptionStatus: 'UNPAID' });
await createUser({ subscriptionStatus: 'INCOMPLETE' });
await createUser({ subscriptionStatus: 'INCOMPLETE_EXPIRED' });
stubStripePrice({ unit_amount: 1900, currency: 'usd' });
const stats = await getCachedStripeStats();
expect(stats).toEqual({
activeSubscribers: 0,
trialingUsers: 0,
pastDueUsers: 0,
canceledUsers: 0,
freeUsers: 0,
otherStatusUsers: 3,
mrrCents: 0,
currency: 'usd',
});
});
it('reports zeros and no revenue on an empty database', async () => {
stubStripePrice({ unit_amount: 1900, currency: 'usd' });
expect(await getCachedStripeStats()).toEqual({
activeSubscribers: 0,
trialingUsers: 0,
pastDueUsers: 0,
canceledUsers: 0,
freeUsers: 0,
otherStatusUsers: 0,
mrrCents: 0,
currency: 'usd',
});
});
// The user counts come from the database and are still true when Stripe is
// down, so the module keeps them and only the revenue figure degrades.
it('keeps the user counts and degrades the revenue to zero usd when Stripe fails', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
await createUser({ subscriptionStatus: 'TRIALING' });
vi.mocked(getStripe as unknown as () => unknown).mockImplementation(() => {
throw new Error('Stripe is unreachable');
});
expect(await getCachedStripeStats()).toEqual({
activeSubscribers: 1,
trialingUsers: 1,
pastDueUsers: 0,
canceledUsers: 0,
freeUsers: 0,
otherStatusUsers: 0,
mrrCents: 0,
currency: 'usd',
});
});
it('degrades the revenue when the price lookup itself rejects', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
prices: {
retrieve: vi.fn(async () => {
throw new Error('price not found');
}),
},
});
const stats = await getCachedStripeStats();
expect(stats?.activeSubscribers).toBe(1);
expect(stats?.mrrCents).toBe(0);
expect(stats?.currency).toBe('usd');
});
it('treats a price with no unit amount as free rather than as NaN', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
stubStripePrice({ unit_amount: null, currency: 'gbp' });
const stats = await getCachedStripeStats();
expect(stats?.mrrCents).toBe(0);
expect(stats?.currency).toBe('gbp');
});
it('answers null without querying Stripe when the billing flag is off', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const stripe = stubStripePrice({ unit_amount: 1900, currency: 'usd' });
expect(await getCachedStripeStats()).toBeNull();
expect(stripe.retrievedPriceIds).toEqual([]);
});
it('answers null when the flag is on but Stripe is not configured', async () => {
await createUser({ subscriptionStatus: 'ACTIVE' });
vi.stubEnv('STRIPE_SECRET_KEY', undefined);
expect(await getCachedStripeStats()).toBeNull();
});
});