mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Merge pull request #54 from yusufipk/fix/bunny-upload-reservation
fix(uploads): count a Bunny upload from the moment it is admitted
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
// The Bunny init routes used to ask the quota about zero bytes, which meant two
|
||||
// things at once: an upload that could never fit was only discovered after it had
|
||||
// been sent, and nothing an upload was about to consume was visible to the next
|
||||
// request. Bunny reports its own storage on a delay and the figure is cached for
|
||||
// two minutes on top, so every init inside that window read the same stale total
|
||||
// and every one of them passed.
|
||||
//
|
||||
// These tests pin the two halves of the fix: the declared size is checked before
|
||||
// a byte moves, and it is held as a reservation the next init has to see.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
DELETE as cancelProjectBunnyUpload,
|
||||
POST as initProjectBunnyUpload,
|
||||
} from '@/app/api/projects/[projectId]/videos/bunny-init/route';
|
||||
import { POST as createAsset } from '@/app/api/videos/[videoId]/assets/route';
|
||||
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
|
||||
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
|
||||
import { signedInAs } from '../helpers/session';
|
||||
import { createVideo, seedProject } from '../factories';
|
||||
|
||||
const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024);
|
||||
|
||||
/**
|
||||
* Bunny answers every call the same way, with a fresh video id each time so two
|
||||
* inits in one test are distinguishable. The cancel path talks to Bunny too, so
|
||||
* the stub has to cover it rather than just the creation call.
|
||||
*/
|
||||
function stubBunnyApi(): void {
|
||||
let created = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
created += 1;
|
||||
return new Response(JSON.stringify({ guid: `bunnyvideo-${created}-abcdefgh` }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
|
||||
vi.stubEnv('OPENFRAME_ENABLE_BUNNY_UPLOADS', 'true');
|
||||
vi.stubEnv('OPENFRAME_ENABLE_S3_VIDEO_UPLOADS', 'false');
|
||||
vi.stubEnv('BUNNY_STREAM_API_KEY', 'test-bunny-key');
|
||||
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '999999');
|
||||
vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', 'test-bunny-upload-token-secret');
|
||||
stubBunnyApi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function initRequest(projectId: string, body: Record<string, unknown>) {
|
||||
return apiRequest(`/api/projects/${projectId}/videos/bunny-init`, { body });
|
||||
}
|
||||
|
||||
async function initUpload(projectId: string, sizeBytes: bigint) {
|
||||
return callRoute(
|
||||
initProjectBunnyUpload,
|
||||
initRequest(projectId, { title: 'A clip', sizeBytes: sizeBytes.toString() }),
|
||||
{ projectId }
|
||||
);
|
||||
}
|
||||
|
||||
describe('POST /api/projects/[projectId]/videos/bunny-init', () => {
|
||||
it('refuses an init that does not say how big the upload is', async () => {
|
||||
const scenario = await seedProject();
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const response = await callRoute(
|
||||
initProjectBunnyUpload,
|
||||
initRequest(scenario.project.id, { title: 'A clip' }),
|
||||
{ projectId: scenario.project.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await readError(response)).toContain('sizeBytes');
|
||||
expect(await db.uploadReservation.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses a size beyond the host per-file ceiling', async () => {
|
||||
vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', '1024');
|
||||
const scenario = await seedProject();
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const response = await initUpload(scenario.project.id, BigInt(2048));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await readError(response)).toContain('maximum allowed upload size');
|
||||
});
|
||||
|
||||
// The trial ceiling is 3 GiB, so this is refused on the way in rather than
|
||||
// after four gigabytes have been pushed to Bunny.
|
||||
it('refuses an upload the remaining quota cannot hold', async () => {
|
||||
const scenario = await seedProject();
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const response = await initUpload(scenario.project.id, BigInt(4) * GIB);
|
||||
|
||||
expect(response.status).toBe(507);
|
||||
expect(await db.uploadReservation.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('holds the declared size as a reservation for the workspace owner', async () => {
|
||||
const scenario = await seedProject();
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const response = await initUpload(scenario.project.id, BigInt(2) * GIB);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const reservations = await db.uploadReservation.findMany();
|
||||
expect(reservations).toHaveLength(1);
|
||||
expect(reservations[0].billedUserId).toBe(scenario.owner.id);
|
||||
expect(reservations[0].sizeBytes).toBe(BigInt(2) * GIB);
|
||||
});
|
||||
|
||||
// The regression this whole change exists for. Both of these used to be
|
||||
// granted, because neither could see what the other was about to upload.
|
||||
it('refuses a second upload that no longer fits beside the first', async () => {
|
||||
const scenario = await seedProject();
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const first = await initUpload(scenario.project.id, BigInt(2) * GIB);
|
||||
const second = await initUpload(scenario.project.id, BigInt(2) * GIB);
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(507);
|
||||
expect(await db.uploadReservation.count()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/projects/[projectId]/videos/bunny-init', () => {
|
||||
it('gives the quota back when a pending upload is cancelled', async () => {
|
||||
const scenario = await seedProject();
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const init = await initUpload(scenario.project.id, BigInt(2) * GIB);
|
||||
const { videoId, uploadToken } = await readData(init);
|
||||
|
||||
const response = await callRoute(
|
||||
cancelProjectBunnyUpload,
|
||||
initRequest(scenario.project.id, { videoId, uploadToken }),
|
||||
{ projectId: scenario.project.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await db.uploadReservation.count()).toBe(0);
|
||||
});
|
||||
|
||||
// Why the reservation id travels inside the signed token instead of being
|
||||
// handed to the client as a field of its own: a caller who could name a
|
||||
// reservation could start two uploads, cancel the cheap one while quoting the
|
||||
// expensive one's reservation, and keep uploading against quota it no longer
|
||||
// holds.
|
||||
it('will not let one upload cancel release another upload reservation', async () => {
|
||||
const scenario = await seedProject();
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const first = await initUpload(scenario.project.id, BigInt(1) * GIB);
|
||||
const second = await initUpload(scenario.project.id, BigInt(1) * GIB);
|
||||
const firstData = await readData(first);
|
||||
const secondData = await readData(second);
|
||||
|
||||
const response = await callRoute(
|
||||
cancelProjectBunnyUpload,
|
||||
initRequest(scenario.project.id, {
|
||||
videoId: secondData.videoId,
|
||||
uploadToken: firstData.uploadToken,
|
||||
}),
|
||||
{ projectId: scenario.project.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await db.uploadReservation.count()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// The reservation id is not a secret and was never going to be one. The upload
|
||||
// token is `base64url(payload).signature`, so the client can read every claim in
|
||||
// it, and the two R2 upload routes hand their reservation ids to the client
|
||||
// outright. What keeps a hold from being dropped by whoever can name it is that
|
||||
// a reservation records what it was opened for, and every finalize route matches
|
||||
// on that as well as on the id.
|
||||
describe('a Bunny hold cannot be consumed by another flow', () => {
|
||||
/** The claims the client can read out of an upload token without our help. */
|
||||
function claimsOf(uploadToken: string): Record<string, unknown> {
|
||||
return JSON.parse(Buffer.from(uploadToken.split('.')[0], 'base64url').toString('utf8'));
|
||||
}
|
||||
|
||||
it('puts the reservation id somewhere the client can read it', async () => {
|
||||
const scenario = await seedProject();
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const init = await initUpload(scenario.project.id, BigInt(1) * GIB);
|
||||
const { uploadToken } = await readData(init);
|
||||
|
||||
const reservation = (await db.uploadReservation.findMany())[0];
|
||||
expect(claimsOf(uploadToken).rid).toBe(reservation.id);
|
||||
expect(reservation.purpose).toBe(UPLOAD_RESERVATION_PURPOSES.BUNNY);
|
||||
});
|
||||
|
||||
// The attack the purpose column closes. Creating a YouTube asset costs nothing
|
||||
// and consumes no storage, so quoting a Bunny reservation on one was a way to
|
||||
// hand back the quota of an upload that was still running and then start
|
||||
// another. Repeat and a trial worth three gigabytes uploads as much as it
|
||||
// likes for as long as Bunny takes to report.
|
||||
it('ignores a Bunny reservation id quoted on a YouTube asset create', async () => {
|
||||
const scenario = await seedProject();
|
||||
const video = await createVideo({ projectId: scenario.project.id });
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const init = await initUpload(scenario.project.id, BigInt(2) * GIB);
|
||||
const { uploadToken } = await readData(init);
|
||||
const reservationId = claimsOf(uploadToken).rid as string;
|
||||
|
||||
const response = await callRoute(
|
||||
createAsset,
|
||||
apiRequest(`/api/videos/${video.id}/assets`, {
|
||||
body: {
|
||||
provider: 'YOUTUBE',
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
reservationId,
|
||||
},
|
||||
}),
|
||||
{ videoId: video.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const reservations = await db.uploadReservation.findMany();
|
||||
expect(reservations).toHaveLength(1);
|
||||
expect(reservations[0].id).toBe(reservationId);
|
||||
});
|
||||
|
||||
// And with the hold still standing, the next init has to see it.
|
||||
it('still refuses the next upload after the quoted release attempt', async () => {
|
||||
const scenario = await seedProject();
|
||||
const video = await createVideo({ projectId: scenario.project.id });
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const init = await initUpload(scenario.project.id, BigInt(2) * GIB);
|
||||
const { uploadToken } = await readData(init);
|
||||
|
||||
await callRoute(
|
||||
createAsset,
|
||||
apiRequest(`/api/videos/${video.id}/assets`, {
|
||||
body: {
|
||||
provider: 'YOUTUBE',
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
reservationId: claimsOf(uploadToken).rid,
|
||||
},
|
||||
}),
|
||||
{ videoId: video.id }
|
||||
);
|
||||
|
||||
const second = await initUpload(scenario.project.id, BigInt(2) * GIB);
|
||||
expect(second.status).toBe(507);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { db } from '@/lib/db';
|
||||
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
|
||||
import { cancelR2UploadSession, createR2UploadSession } from '@/lib/r2-upload-session';
|
||||
import { seedProject } from '../factories';
|
||||
|
||||
@@ -101,6 +102,7 @@ describe('createR2UploadSession', () => {
|
||||
data: {
|
||||
billedUserId: scenario.owner.id,
|
||||
sizeBytes: BigInt(4096),
|
||||
purpose: UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
|
||||
expiresAt: new Date(Date.now() + HOUR_MS),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { db } from '@/lib/db';
|
||||
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
|
||||
import { deleteR2Object, deleteVideoObject, headVideoObject, readVideoObjectBytes } from '@/lib/r2';
|
||||
import { createR2UploadToken } from '@/lib/r2-upload-token';
|
||||
import { createR2UploadSession } from '@/lib/r2-upload-session';
|
||||
@@ -515,6 +516,7 @@ describe('finalizeR2VideoUpload success', () => {
|
||||
data: {
|
||||
billedUserId: scenario.owner.id,
|
||||
sizeBytes: BigInt(4096),
|
||||
purpose: UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
|
||||
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { db } from '@/lib/db';
|
||||
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
||||
import {
|
||||
PLAN_STORAGE_LIMIT_BYTES,
|
||||
UPLOAD_RESERVATION_PURPOSES,
|
||||
enforceStorageQuota,
|
||||
getUserStorageInfo,
|
||||
getUserTotalStorageBytes,
|
||||
@@ -83,7 +84,11 @@ describe('the trial ceiling', () => {
|
||||
const user = await createUser();
|
||||
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(2) * GIB });
|
||||
|
||||
const result = await reserveStorageQuota(user.id, BigInt(2) * GIB);
|
||||
const result = await reserveStorageQuota(
|
||||
user.id,
|
||||
BigInt(2) * GIB,
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
|
||||
expect('error' in result).toBe(true);
|
||||
expect((result as { error: Response }).error.status).toBe(507);
|
||||
@@ -92,7 +97,11 @@ describe('the trial ceiling', () => {
|
||||
it('still lets a trial account upload inside its own ceiling', async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const result = await reserveStorageQuota(user.id, BigInt(1) * GIB);
|
||||
const result = await reserveStorageQuota(
|
||||
user.id,
|
||||
BigInt(1) * GIB,
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
|
||||
expect('reservationId' in result).toBe(true);
|
||||
});
|
||||
@@ -277,7 +286,11 @@ describe('reserveStorageQuota', () => {
|
||||
it('writes a reservation row billed to the user with the requested size', async () => {
|
||||
const user = await createSubscribedUser();
|
||||
|
||||
const result = await reserveStorageQuota(user.id, BigInt(4096));
|
||||
const result = await reserveStorageQuota(
|
||||
user.id,
|
||||
BigInt(4096),
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
|
||||
expect('reservationId' in result).toBe(true);
|
||||
const reservation = await db.uploadReservation.findFirstOrThrow();
|
||||
@@ -291,7 +304,11 @@ describe('reserveStorageQuota', () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
||||
const user = await createSubscribedUser();
|
||||
|
||||
const result = await reserveStorageQuota(user.id, BigInt(4096));
|
||||
const result = await reserveStorageQuota(
|
||||
user.id,
|
||||
BigInt(4096),
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
|
||||
expect(result).toEqual({ reservationId: null });
|
||||
expect(await db.uploadReservation.count()).toBe(0);
|
||||
@@ -304,7 +321,11 @@ describe('reserveStorageQuota', () => {
|
||||
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
||||
});
|
||||
|
||||
const result = await reserveStorageQuota(user.id, BigInt(2048));
|
||||
const result = await reserveStorageQuota(
|
||||
user.id,
|
||||
BigInt(2048),
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
|
||||
expect('error' in result).toBe(true);
|
||||
expect('error' in result && result.error.status).toBe(507);
|
||||
@@ -321,7 +342,11 @@ describe('reserveStorageQuota', () => {
|
||||
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(100),
|
||||
});
|
||||
|
||||
const result = await reserveStorageQuota(scenario.owner.id, BigInt(200));
|
||||
const result = await reserveStorageQuota(
|
||||
scenario.owner.id,
|
||||
BigInt(200),
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
|
||||
expect('error' in result).toBe(true);
|
||||
expect(await db.uploadReservation.count()).toBe(0);
|
||||
@@ -331,7 +356,11 @@ describe('reserveStorageQuota', () => {
|
||||
const user = await createSubscribedUser();
|
||||
bunnyStorage({ [user.id]: Number(PLAN_STORAGE_LIMIT_BYTES - BigInt(1024)) });
|
||||
|
||||
const result = await reserveStorageQuota(user.id, BigInt(2048));
|
||||
const result = await reserveStorageQuota(
|
||||
user.id,
|
||||
BigInt(2048),
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
|
||||
expect('error' in result).toBe(true);
|
||||
expect(await db.uploadReservation.count()).toBe(0);
|
||||
@@ -345,7 +374,11 @@ describe('reserveStorageQuota', () => {
|
||||
expiresInMs: -60_000,
|
||||
});
|
||||
|
||||
const result = await reserveStorageQuota(user.id, BigInt(2048));
|
||||
const result = await reserveStorageQuota(
|
||||
user.id,
|
||||
BigInt(2048),
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
|
||||
expect('reservationId' in result).toBe(true);
|
||||
});
|
||||
@@ -358,7 +391,11 @@ describe('reserveStorageQuota', () => {
|
||||
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
||||
});
|
||||
|
||||
const result = await reserveStorageQuota(light.id, BigInt(10) * GIB);
|
||||
const result = await reserveStorageQuota(
|
||||
light.id,
|
||||
BigInt(10) * GIB,
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
|
||||
expect('reservationId' in result).toBe(true);
|
||||
});
|
||||
@@ -375,8 +412,8 @@ describe('reserveStorageQuota', () => {
|
||||
expect(headroom(used)).toBe(BigInt(30) * GIB);
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
reserveStorageQuota(user.id, request),
|
||||
reserveStorageQuota(user.id, request),
|
||||
reserveStorageQuota(user.id, request, UPLOAD_RESERVATION_PURPOSES.R2_VIDEO),
|
||||
reserveStorageQuota(user.id, request, UPLOAD_RESERVATION_PURPOSES.R2_VIDEO),
|
||||
]);
|
||||
|
||||
const granted = [first, second].filter((result) => 'reservationId' in result);
|
||||
@@ -401,8 +438,8 @@ describe('reserveStorageQuota', () => {
|
||||
const request = BigInt(20) * GIB;
|
||||
|
||||
const results = await Promise.all([
|
||||
reserveStorageQuota(user.id, request),
|
||||
reserveStorageQuota(user.id, request),
|
||||
reserveStorageQuota(user.id, request, UPLOAD_RESERVATION_PURPOSES.R2_VIDEO),
|
||||
reserveStorageQuota(user.id, request, UPLOAD_RESERVATION_PURPOSES.R2_VIDEO),
|
||||
]);
|
||||
|
||||
expect(results.every((result) => 'reservationId' in result)).toBe(true);
|
||||
@@ -417,7 +454,9 @@ describe('reserveStorageQuota', () => {
|
||||
const request = BigInt(20) * GIB;
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 5 }, () => reserveStorageQuota(user.id, request))
|
||||
Array.from({ length: 5 }, () =>
|
||||
reserveStorageQuota(user.id, request, UPLOAD_RESERVATION_PURPOSES.R2_VIDEO)
|
||||
)
|
||||
);
|
||||
|
||||
const granted = results.filter((result) => 'reservationId' in result);
|
||||
@@ -438,8 +477,8 @@ describe('reserveStorageQuota', () => {
|
||||
const request = BigInt(150) * GIB;
|
||||
|
||||
const results = await Promise.all([
|
||||
reserveStorageQuota(first.id, request),
|
||||
reserveStorageQuota(second.id, request),
|
||||
reserveStorageQuota(first.id, request, UPLOAD_RESERVATION_PURPOSES.R2_VIDEO),
|
||||
reserveStorageQuota(second.id, request, UPLOAD_RESERVATION_PURPOSES.R2_VIDEO),
|
||||
]);
|
||||
|
||||
expect(results.every((result) => 'reservationId' in result)).toBe(true);
|
||||
@@ -450,7 +489,11 @@ describe('reserveStorageQuota', () => {
|
||||
describe('releaseStorageReservation', () => {
|
||||
it('deletes the reservation and frees the headroom', async () => {
|
||||
const user = await createSubscribedUser();
|
||||
const result = await reserveStorageQuota(user.id, BigInt(10) * GIB);
|
||||
const result = await reserveStorageQuota(
|
||||
user.id,
|
||||
BigInt(10) * GIB,
|
||||
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||
);
|
||||
const reservationId = 'reservationId' in result ? result.reservationId : null;
|
||||
expect(reservationId).toBeTruthy();
|
||||
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(10) * GIB);
|
||||
@@ -484,6 +527,25 @@ describe('releaseStorageReservation', () => {
|
||||
|
||||
expect(await db.uploadReservation.count()).toBe(1);
|
||||
});
|
||||
|
||||
// The same scoping in the other direction. Every hold an account owns is
|
||||
// billed to the same user, so `billedUserId` alone does not separate them: an
|
||||
// image being attached would release a Bunny upload that was still in flight
|
||||
// if it could name it, and the ids are readable by the client.
|
||||
it('refuses to delete a reservation opened for a different flow', async () => {
|
||||
const owner = await createSubscribedUser();
|
||||
const reservation = await createUploadReservation({
|
||||
billedUserId: owner.id,
|
||||
sizeBytes: BigInt(4096),
|
||||
purpose: UPLOAD_RESERVATION_PURPOSES.BUNNY,
|
||||
});
|
||||
|
||||
await releaseStorageReservation(reservation.id, owner.id, UPLOAD_RESERVATION_PURPOSES.IMAGE);
|
||||
expect(await db.uploadReservation.count()).toBe(1);
|
||||
|
||||
await releaseStorageReservation(reservation.id, owner.id, UPLOAD_RESERVATION_PURPOSES.BUNNY);
|
||||
expect(await db.uploadReservation.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/settings/storage', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useState } from 'react';
|
||||
import { useState, type ChangeEvent } from 'react';
|
||||
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
||||
import { useCommentActions } from '@/components/video-page/hooks/use-comment-actions';
|
||||
import type { Comment, CommentTag, VideoData } from '@/components/video-page/types';
|
||||
@@ -293,6 +293,72 @@ describe('useCommentActions adding a comment', () => {
|
||||
expect(harness.result.current.actions.isSubmittingComment).toBe(false);
|
||||
});
|
||||
|
||||
// The attachment goes up before the comment does, so a full account fails on
|
||||
// the image and never reaches the comment at all. Reporting that as a comment
|
||||
// that would not post told the uploader to try again, which is the one thing
|
||||
// that cannot work.
|
||||
it('reads out the storage error the attachment upload came back with', async () => {
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url === '/api/upload/image') {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 507,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
error: 'Storage limit exceeded. Please delete some files to free up space.',
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve(ok({ data: serverComment }));
|
||||
});
|
||||
const harness = renderActions();
|
||||
|
||||
// A one-pixel PNG header is enough: the client only sniffs the magic bytes.
|
||||
const png = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
'n.png',
|
||||
{
|
||||
type: 'image/png',
|
||||
}
|
||||
);
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.handleImageSelect({
|
||||
target: { files: [png] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
act(() => harness.result.current.actions.setCommentText('Colour is off'));
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.handleAddComment();
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith(
|
||||
'Storage limit exceeded. Please delete some files to free up space.'
|
||||
);
|
||||
expect(commentIds(harness)).toEqual(['c1', 'c2']);
|
||||
});
|
||||
|
||||
it('reads out the storage error the comment itself came back with', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 507,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
error: 'Storage limit exceeded. Please delete some files to free up space.',
|
||||
}),
|
||||
});
|
||||
const harness = renderActions();
|
||||
|
||||
act(() => harness.result.current.actions.setCommentText('Colour is off'));
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.handleAddComment();
|
||||
});
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith(
|
||||
'Storage limit exceeded. Please delete some files to free up space.'
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls the comment back out of the list when the request throws', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
const harness = renderActions();
|
||||
|
||||
@@ -419,7 +419,13 @@ describe('useVersionActions uploading a file to Bunny', () => {
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(bodyOf(callsTo(BUNNY_INIT_URL, 'POST')[0])).toEqual({ title: 'my clip' });
|
||||
// The size goes with the title: the server checks it against the quota and
|
||||
// reserves it before Bunny is asked for anything, so an upload that cannot
|
||||
// fit is refused here rather than after it has been sent.
|
||||
expect(bodyOf(callsTo(BUNNY_INIT_URL, 'POST')[0])).toEqual({
|
||||
title: 'my clip',
|
||||
sizeBytes: '10',
|
||||
});
|
||||
expect(tusUploads[0].options.endpoint).toBe('https://video.bunnycdn.com/tusupload');
|
||||
expect(tusUploads[0].options.headers).toEqual({
|
||||
AuthorizationSignature: 'sig',
|
||||
@@ -436,7 +442,10 @@ describe('useVersionActions uploading a file to Bunny', () => {
|
||||
|
||||
await createFromFile(harness);
|
||||
|
||||
expect(bodyOf(callsTo(BUNNY_INIT_URL, 'POST')[0])).toEqual({ title: 'Client cut' });
|
||||
expect(bodyOf(callsTo(BUNNY_INIT_URL, 'POST')[0])).toEqual({
|
||||
title: 'Client cut',
|
||||
sizeBytes: '10',
|
||||
});
|
||||
});
|
||||
|
||||
it('registers the version against the Bunny embed and CDN thumbnail', async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type VideoVersion,
|
||||
} from '@prisma/client';
|
||||
import { db } from '@/lib/db';
|
||||
import { UPLOAD_RESERVATION_PURPOSES, type UploadReservationPurpose } from '@/lib/storage-quota';
|
||||
import { nextSeq } from './seq';
|
||||
|
||||
export interface CreateVideoInput {
|
||||
@@ -105,6 +106,8 @@ export interface CreateUploadReservationInput {
|
||||
sizeBytes: bigint;
|
||||
/** Milliseconds from now. Negative values produce an already-expired row. */
|
||||
expiresInMs?: number;
|
||||
/** Which flow the hold belongs to. Only that flow can consume it. */
|
||||
purpose?: UploadReservationPurpose;
|
||||
}
|
||||
|
||||
export async function createUploadReservation(
|
||||
@@ -115,6 +118,7 @@ export async function createUploadReservation(
|
||||
billedUserId: input.billedUserId,
|
||||
sizeBytes: input.sizeBytes,
|
||||
expiresAt: new Date(Date.now() + (input.expiresInMs ?? 30 * 60 * 1000)),
|
||||
purpose: input.purpose ?? UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ const REVIEWED_MIGRATIONS = [
|
||||
'20260614160000_add_project_allow_downloads',
|
||||
'20260627140000_add_video_upload_multipart_id',
|
||||
'20260801120000_add_acquisition_analytics',
|
||||
'20260818120000_add_upload_reservation_purpose',
|
||||
];
|
||||
|
||||
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// The grant a share-link visitor gets for a direct upload.
|
||||
//
|
||||
// A guest's upload is billed to the workspace owner, not to the guest, so this
|
||||
// token is the only thing tying what they declared and what they hold to the
|
||||
// upload it was issued for. Two claims matter here beyond the existing subject
|
||||
// binding: the provider's own video id, which is what makes releasing the hold
|
||||
// on the guest's say-so safe, and the declared size, which is what the asset is
|
||||
// charged until Bunny reports a figure of its own.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createGuestUploadToken,
|
||||
readGuestUploadGrant,
|
||||
verifyGuestUploadToken,
|
||||
} from '@/lib/guest-upload-token';
|
||||
|
||||
const SUBJECT = {
|
||||
projectId: 'project-1',
|
||||
videoId: 'video-1',
|
||||
intent: 'bunny' as const,
|
||||
context: '203.0.113.7:public',
|
||||
};
|
||||
|
||||
const BUNNY_VIDEO_ID = 'bunnyvideo-1-abcdefgh';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('GUEST_UPLOAD_TOKEN_SECRET', 'test-guest-upload-token-secret');
|
||||
});
|
||||
|
||||
describe('readGuestUploadGrant', () => {
|
||||
it('carries back the reservation and the declared size it was signed with', () => {
|
||||
const token = createGuestUploadToken({
|
||||
...SUBJECT,
|
||||
providerVideoId: BUNNY_VIDEO_ID,
|
||||
reservationId: 'reservation-1',
|
||||
declaredSizeBytes: BigInt(4096),
|
||||
});
|
||||
|
||||
expect(readGuestUploadGrant(token, SUBJECT, BUNNY_VIDEO_ID)).toEqual({
|
||||
reservationId: 'reservation-1',
|
||||
declaredSizeBytes: BigInt(4096),
|
||||
});
|
||||
});
|
||||
|
||||
// The binding that makes a guest release safe: presenting this token to cancel
|
||||
// deletes the upload it stands for, so it cannot be used to drop the hold of
|
||||
// an upload that is still running.
|
||||
it('refuses a grant presented against a different provider video', () => {
|
||||
const token = createGuestUploadToken({
|
||||
...SUBJECT,
|
||||
providerVideoId: BUNNY_VIDEO_ID,
|
||||
reservationId: 'reservation-1',
|
||||
});
|
||||
|
||||
expect(readGuestUploadGrant(token, SUBJECT, 'bunnyvideo-2-abcdefgh')).toBeNull();
|
||||
expect(readGuestUploadGrant(token, SUBJECT, null)).toBeNull();
|
||||
expect(verifyGuestUploadToken(token, SUBJECT, 'bunnyvideo-2-abcdefgh')).toBe(false);
|
||||
});
|
||||
|
||||
it('still refuses a grant for another subject, bound video or not', () => {
|
||||
const token = createGuestUploadToken({
|
||||
...SUBJECT,
|
||||
providerVideoId: BUNNY_VIDEO_ID,
|
||||
reservationId: 'reservation-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
readGuestUploadGrant(token, { ...SUBJECT, videoId: 'video-2' }, BUNNY_VIDEO_ID)
|
||||
).toBeNull();
|
||||
expect(readGuestUploadGrant(token, { ...SUBJECT, intent: 'image' }, BUNNY_VIDEO_ID)).toBeNull();
|
||||
expect(
|
||||
readGuestUploadGrant(token, { ...SUBJECT, context: '198.51.100.9:public' }, BUNNY_VIDEO_ID)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// The image and audio grants carry none of this, and a grant issued before the
|
||||
// claims existed keeps working rather than failing an upload in flight.
|
||||
it('reads a grant with no claims as holding nothing', () => {
|
||||
const token = createGuestUploadToken({ ...SUBJECT, intent: 'image' });
|
||||
const subject = { ...SUBJECT, intent: 'image' as const };
|
||||
|
||||
expect(readGuestUploadGrant(token, subject)).toEqual({
|
||||
reservationId: null,
|
||||
declaredSizeBytes: null,
|
||||
});
|
||||
expect(verifyGuestUploadToken(token, subject, BUNNY_VIDEO_ID)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a forged signature', () => {
|
||||
const token = createGuestUploadToken({
|
||||
...SUBJECT,
|
||||
providerVideoId: BUNNY_VIDEO_ID,
|
||||
reservationId: 'reservation-1',
|
||||
});
|
||||
const [payload] = token.split('.');
|
||||
|
||||
expect(readGuestUploadGrant(`${payload}.forged`, SUBJECT, BUNNY_VIDEO_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a non-positive declared size as nothing declared', () => {
|
||||
const token = createGuestUploadToken({
|
||||
...SUBJECT,
|
||||
providerVideoId: BUNNY_VIDEO_ID,
|
||||
declaredSizeBytes: BigInt(-1),
|
||||
});
|
||||
|
||||
expect(readGuestUploadGrant(token, SUBJECT, BUNNY_VIDEO_ID)?.declaredSizeBytes).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseDeclaredUploadSize } from '@/lib/upload-size';
|
||||
|
||||
const MAX = BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024);
|
||||
|
||||
function size(result: ReturnType<typeof parseDeclaredUploadSize>): bigint | null {
|
||||
return 'sizeBytes' in result ? result.sizeBytes : null;
|
||||
}
|
||||
|
||||
describe('parseDeclaredUploadSize', () => {
|
||||
it('accepts a size sent as a string, which is how a client sends bytes it cannot hold in a number', () => {
|
||||
expect(size(parseDeclaredUploadSize('4294967296', MAX))).toBe(BigInt(4294967296));
|
||||
});
|
||||
|
||||
it('accepts a size sent as a number', () => {
|
||||
expect(size(parseDeclaredUploadSize(1024, MAX))).toBe(BigInt(1024));
|
||||
});
|
||||
|
||||
it('rejects a missing size', () => {
|
||||
expect(parseDeclaredUploadSize(undefined, MAX)).toEqual({
|
||||
error: 'sizeBytes must be a positive integer',
|
||||
});
|
||||
});
|
||||
|
||||
// Zero was the old behaviour of every Bunny init: it asked the quota whether
|
||||
// it could store nothing, and the answer was always yes.
|
||||
it('rejects zero', () => {
|
||||
expect(parseDeclaredUploadSize(0, MAX)).toEqual({
|
||||
error: 'sizeBytes must be a positive integer',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a negative size', () => {
|
||||
expect(parseDeclaredUploadSize(-1, MAX)).toEqual({
|
||||
error: 'sizeBytes must be a positive integer',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a fractional size rather than rounding it', () => {
|
||||
expect(parseDeclaredUploadSize(1.5, MAX)).toEqual({
|
||||
error: 'sizeBytes must be a positive integer',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects text that is not a number', () => {
|
||||
expect(parseDeclaredUploadSize('a lot', MAX)).toEqual({
|
||||
error: 'sizeBytes must be a positive integer',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a size over the ceiling', () => {
|
||||
expect(parseDeclaredUploadSize(MAX + BigInt(1), MAX)).toEqual({
|
||||
error: 'File exceeds the maximum allowed upload size',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a size exactly at the ceiling', () => {
|
||||
expect(size(parseDeclaredUploadSize(MAX, MAX))).toBe(MAX);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user