mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Second pass over the suite, driven by the inventory in the gaps document. Nine agents wrote suites in parallel against private databases, then a tenth read all of it adversarially and five of its findings were fixed. unit + component 2076 -> 2079 (+888 over the round) api 647 -> 1015 e2e 18 -> 29 What was closed: - lib/route-access.ts, the page-level authorization layer, went from zero tests to 48. Every API route was guarded and none of the pages were. - The five media proxy routes now have a real 2xx beside every 403. The blocker was the positive control, solved by stubbing r2Client.send() and leaving lib/r2-media-proxy.ts itself real. - Every remaining server-side lib module: invitations, email verification, the upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle, notifications and admin stats. - Six video-page hooks, and the chunking arithmetic extracted out of lib/client/r2-video-upload.ts as a pure module. - Five end-to-end flows: workspace members, bulk operations, the admin area, player interaction and failure recovery. Three things about the harness itself turned out to be wrong: - Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every route reaching finalizeR2VideoUpload silently took the "not a valid video" branch and no test noticed. - The auth matrix asserted only "not 2xx", which two entries satisfied without their guard existing. It now requires 401 or 403, which makes both load-bearing, and all 60 routes pass the stricter form. - Both admin API routes had no positive control anywhere: replacing their guard with an unconditional refusal left the entire suite green. Found by the adversarial review, now covered. Process: - bun run test:mutation runs StrykerJS over the authorization and validation modules. Diagnostic, not a gate, weekly in CI rather than on a push. - playwright.config.ts gains an opt-in webkit project for the player spec. - AGENTS.md now requires a batch of new tests to be reviewed by somebody who did not write them. Only two production files change, both deliberate: lib/auth.ts loses a verbatim copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the extracted arithmetic. No behaviour change in either.
556 lines
20 KiB
TypeScript
556 lines
20 KiB
TypeScript
// Exercises lib/r2-video-finalize.ts, the gate between "the browser says it
|
|
// finished uploading" and "a row is written that bills the user for it".
|
|
//
|
|
// Everything the caller supplies is hostile until proven otherwise: the object
|
|
// key, the proxy URL and the upload token all arrive in the request body. The
|
|
// module checks them against each other and then against the session row, and
|
|
// only then trusts storage. Each of those checks gets a test, because any one
|
|
// of them going missing turns the endpoint into "tell me a size and I will
|
|
// believe you".
|
|
//
|
|
// The failure branches matter as much as the happy path: when the object is
|
|
// missing, oversized or not a video, the session is cancelled and both objects
|
|
// are removed. Leaving an INITIATED session behind would pin the quota
|
|
// reservation for its whole TTL.
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { randomUUID } from 'crypto';
|
|
import { db } from '@/lib/db';
|
|
import { deleteR2Object, deleteVideoObject, headVideoObject, readVideoObjectBytes } from '@/lib/r2';
|
|
import { createR2UploadToken } from '@/lib/r2-upload-token';
|
|
import { createR2UploadSession } from '@/lib/r2-upload-session';
|
|
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
|
|
import { createUser, seedProject } from '../factories';
|
|
|
|
/** 12 bytes whose 5th to 8th spell `ftyp`, the ISO base media signature. */
|
|
function mp4Header(): Uint8Array {
|
|
const bytes = new Uint8Array(12);
|
|
bytes.set([0x66, 0x74, 0x79, 0x70], 4);
|
|
return bytes;
|
|
}
|
|
|
|
function bytesOf(...values: number[]): Uint8Array {
|
|
return Uint8Array.from(values);
|
|
}
|
|
|
|
/** Makes storage report an object of `sizeBytes` whose header is `header`. */
|
|
function storageHolds(sizeBytes: number, header: Uint8Array = mp4Header()): void {
|
|
vi.mocked(headVideoObject).mockResolvedValue({
|
|
contentLength: BigInt(sizeBytes),
|
|
contentType: 'video/mp4',
|
|
});
|
|
vi.mocked(readVideoObjectBytes).mockResolvedValue(header);
|
|
}
|
|
|
|
interface SeededUpload {
|
|
userId: string;
|
|
projectId: string;
|
|
sessionId: string;
|
|
objectKey: string;
|
|
thumbnailObjectKey: string;
|
|
proxyUrl: string;
|
|
uploadToken: string;
|
|
reservationId: string | null;
|
|
billedUserId: string;
|
|
}
|
|
|
|
/**
|
|
* An INITIATED session plus the token the r2-init route would have handed back
|
|
* for it, both built through the production helpers rather than by hand.
|
|
*/
|
|
async function seedUpload(
|
|
overrides: {
|
|
declaredSizeBytes?: bigint;
|
|
expiresAt?: Date;
|
|
thumbnailObjectKey?: string;
|
|
reservationId?: string | null;
|
|
billedUserId?: string;
|
|
userId?: string;
|
|
projectId?: string;
|
|
tokenUserId?: string;
|
|
tokenProjectId?: string;
|
|
objectKey?: string;
|
|
} = {}
|
|
): Promise<SeededUpload & { scenarioOwnerId: string }> {
|
|
const scenario = await seedProject();
|
|
const fileId = randomUUID();
|
|
const objectKey = overrides.objectKey ?? `videos/${fileId}.mp4`;
|
|
const thumbnailObjectKey = overrides.thumbnailObjectKey ?? `images/${fileId}.jpg`;
|
|
const uploadJti = randomUUID();
|
|
const userId = overrides.userId ?? scenario.owner.id;
|
|
const projectId = overrides.projectId ?? scenario.project.id;
|
|
const billedUserId = overrides.billedUserId ?? scenario.owner.id;
|
|
|
|
const session = await createR2UploadSession({
|
|
userId,
|
|
projectId,
|
|
billedUserId,
|
|
objectKey,
|
|
thumbnailObjectKey,
|
|
declaredSizeBytes: overrides.declaredSizeBytes ?? BigInt(4096),
|
|
contentType: 'video/mp4',
|
|
reservationId: overrides.reservationId ?? null,
|
|
uploadJti,
|
|
expiresAt: overrides.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000),
|
|
});
|
|
|
|
const uploadToken = createR2UploadToken({
|
|
userId: overrides.tokenUserId ?? userId,
|
|
projectId: overrides.tokenProjectId ?? projectId,
|
|
objectKey,
|
|
sessionId: session.id,
|
|
tokenId: uploadJti,
|
|
thumbnailObjectKey,
|
|
});
|
|
|
|
return {
|
|
userId,
|
|
projectId,
|
|
sessionId: session.id,
|
|
objectKey,
|
|
thumbnailObjectKey,
|
|
proxyUrl: `/api/upload/video/${fileId}.mp4`,
|
|
uploadToken,
|
|
reservationId: session.reservationId,
|
|
billedUserId,
|
|
scenarioOwnerId: scenario.owner.id,
|
|
};
|
|
}
|
|
|
|
function finalize(
|
|
upload: SeededUpload,
|
|
overrides: Partial<Parameters<typeof finalizeR2VideoUpload>[0]> = {}
|
|
) {
|
|
return finalizeR2VideoUpload({
|
|
userId: upload.userId,
|
|
projectId: upload.projectId,
|
|
videoUrl: upload.proxyUrl,
|
|
objectKey: upload.objectKey,
|
|
uploadToken: upload.uploadToken,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
// tests/setup/api.ts already stubs both with the production shapes, but this
|
|
// suite is the one that varies them per test, so it sets its own baseline
|
|
// rather than depending on the shared default staying at 1024 bytes.
|
|
storageHolds(1024);
|
|
vi.mocked(deleteVideoObject).mockClear();
|
|
vi.mocked(deleteR2Object).mockClear();
|
|
});
|
|
|
|
describe('finalizeR2VideoUpload request shape', () => {
|
|
it('rejects a missing objectKey', async () => {
|
|
const upload = await seedUpload();
|
|
|
|
const result = await finalize(upload, { objectKey: '' });
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: 'R2 uploads must include objectKey and uploadToken',
|
|
status: 400,
|
|
});
|
|
});
|
|
|
|
it('rejects a missing uploadToken', async () => {
|
|
const upload = await seedUpload();
|
|
|
|
const result = await finalize(upload, { uploadToken: '' });
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.ok === false && result.status).toBe(400);
|
|
});
|
|
|
|
it('rejects an object key outside the videos prefix', async () => {
|
|
const upload = await seedUpload();
|
|
|
|
const result = await finalize(upload, { objectKey: 'images/not-a-video.jpg' });
|
|
|
|
expect(result).toEqual({ ok: false, error: 'Invalid object key', status: 400 });
|
|
});
|
|
|
|
it('rejects an object key whose basename is not a uuid', async () => {
|
|
const upload = await seedUpload();
|
|
|
|
const result = await finalize(upload, { objectKey: 'videos/../../etc/passwd' });
|
|
|
|
expect(result).toEqual({ ok: false, error: 'Invalid object key', status: 400 });
|
|
});
|
|
|
|
// The proxy URL is what gets written into VideoVersion.originalUrl, so it has
|
|
// to name the same object the token authorises. Otherwise a caller could
|
|
// upload to their own key and point the row at somebody else's file.
|
|
it('rejects a video URL that does not match the object key', async () => {
|
|
const upload = await seedUpload();
|
|
|
|
const result = await finalize(upload, {
|
|
videoUrl: '/api/upload/video/99999999-9999-4999-8999-999999999999.mp4',
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: 'Video URL does not match the uploaded object',
|
|
status: 400,
|
|
});
|
|
});
|
|
|
|
it('rejects a video URL that is not a proxy path at all', async () => {
|
|
const upload = await seedUpload();
|
|
|
|
const result = await finalize(upload, { videoUrl: 'https://cdn.evil.test/clip.mp4' });
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.ok === false && result.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('finalizeR2VideoUpload token verification', () => {
|
|
it('rejects a token that is not even parseable', async () => {
|
|
const upload = await seedUpload();
|
|
|
|
const result = await finalize(upload, { uploadToken: 'forged.token' });
|
|
|
|
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
|
|
});
|
|
|
|
it('rejects a token whose signature has been tampered with', async () => {
|
|
const upload = await seedUpload();
|
|
const [payload] = upload.uploadToken.split('.');
|
|
|
|
const result = await finalize(upload, { uploadToken: `${payload}.notthesignature` });
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.ok === false && result.status).toBe(403);
|
|
});
|
|
|
|
// A validly signed token issued to somebody else. The signature checks out,
|
|
// so only the subject comparison stands between the two accounts.
|
|
it('rejects a well-formed token issued to a different user', async () => {
|
|
const upload = await seedUpload({ tokenUserId: 'someone-else-entirely' });
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
|
|
});
|
|
|
|
it('rejects a well-formed token issued for a different project', async () => {
|
|
const upload = await seedUpload({ tokenProjectId: 'some-other-project' });
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
|
|
});
|
|
|
|
it('rejects a valid token presented by a different caller', async () => {
|
|
const upload = await seedUpload();
|
|
const impostor = await createUser();
|
|
|
|
const result = await finalize(upload, { userId: impostor.id });
|
|
|
|
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
|
|
});
|
|
});
|
|
|
|
describe('finalizeR2VideoUpload session lookup', () => {
|
|
it('rejects a session that has already been cancelled', async () => {
|
|
const upload = await seedUpload();
|
|
await db.videoUploadSession.update({
|
|
where: { id: upload.sessionId },
|
|
data: { status: 'CANCELLED' },
|
|
});
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
|
|
});
|
|
|
|
// Replay protection: a session that already produced a video row must not
|
|
// produce a second one from the same token.
|
|
it('rejects a session that has already been finalized', async () => {
|
|
const upload = await seedUpload();
|
|
await db.videoUploadSession.update({
|
|
where: { id: upload.sessionId },
|
|
data: { status: 'FINALIZED' },
|
|
});
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.ok === false && result.status).toBe(403);
|
|
});
|
|
|
|
it('rejects a session whose expiry has passed', async () => {
|
|
const upload = await seedUpload({ expiresAt: new Date(Date.now() - 60 * 1000) });
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
|
|
});
|
|
|
|
// The thumbnail key is carried in the token and re-read off the session row.
|
|
// A key outside images/ would make the derived proxy URL point nowhere, and
|
|
// the module refuses rather than emitting a half-formed URL.
|
|
it('rejects a session whose thumbnail key is not under the images prefix', async () => {
|
|
const upload = await seedUpload({ thumbnailObjectKey: 'thumbs/elsewhere.jpg' });
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
|
|
});
|
|
});
|
|
|
|
describe('finalizeR2VideoUpload storage checks', () => {
|
|
/** Reads the session row back after a call that should have cancelled it. */
|
|
async function sessionOf(sessionId: string) {
|
|
return db.videoUploadSession.findUniqueOrThrow({ where: { id: sessionId } });
|
|
}
|
|
|
|
it('cancels the session and removes both objects when storage has no object', async () => {
|
|
const upload = await seedUpload();
|
|
vi.mocked(headVideoObject).mockResolvedValue(null);
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: 'Uploaded video was not found in storage',
|
|
status: 400,
|
|
});
|
|
const session = await sessionOf(upload.sessionId);
|
|
expect(session.status).toBe('CANCELLED');
|
|
expect(session.consumedAt).toBeInstanceOf(Date);
|
|
expect(vi.mocked(deleteVideoObject)).toHaveBeenCalledWith(upload.objectKey);
|
|
expect(vi.mocked(deleteR2Object)).toHaveBeenCalledWith(upload.thumbnailObjectKey);
|
|
});
|
|
|
|
it('refuses an object key outside the videos/ prefix without asking storage', async () => {
|
|
// Two things at once, and the second is the reason the stubs are reset.
|
|
//
|
|
// The claim is that finalize refuses a key it has no business finalising
|
|
// *before* it reaches out to storage, so the refusal cannot depend on what
|
|
// the bucket happens to answer. Proving "before" means the storage stubs
|
|
// must not be able to rescue it: this file's beforeEach calls
|
|
// storageHolds(), which mockResolvedValues both functions for any key, so
|
|
// mockReset() puts back the prefix-aware implementations from
|
|
// tests/setup/api.ts (mockReset restores the implementation vi.fn() was
|
|
// created with). Neither is called at all, which is the point.
|
|
vi.mocked(headVideoObject).mockReset();
|
|
vi.mocked(readVideoObjectBytes).mockReset();
|
|
const upload = await seedUpload({ objectKey: `images/${randomUUID()}.mp4` });
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result).toEqual({ ok: false, error: 'Invalid object key', status: 400 });
|
|
expect(vi.mocked(headVideoObject)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(readVideoObjectBytes)).not.toHaveBeenCalled();
|
|
// Refused, not cancelled: nothing was uploaded under a key this session
|
|
// could own, so there is nothing to clean up and the session is left alone.
|
|
expect((await sessionOf(upload.sessionId)).status).toBe('INITIATED');
|
|
});
|
|
|
|
it('cancels when storage reports a zero-byte object', async () => {
|
|
const upload = await seedUpload();
|
|
vi.mocked(headVideoObject).mockResolvedValue({
|
|
contentLength: BigInt(0),
|
|
contentType: 'video/mp4',
|
|
});
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result.ok === false && result.error).toBe('Uploaded video was not found in storage');
|
|
expect((await sessionOf(upload.sessionId)).status).toBe('CANCELLED');
|
|
});
|
|
|
|
it('cancels when the stored object is over the configured maximum', async () => {
|
|
vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', '2048');
|
|
const upload = await seedUpload({ declaredSizeBytes: BigInt(1_000_000) });
|
|
storageHolds(4096);
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: 'Uploaded video exceeds the maximum allowed upload size',
|
|
status: 400,
|
|
});
|
|
expect((await sessionOf(upload.sessionId)).status).toBe('CANCELLED');
|
|
});
|
|
|
|
// The declared size is what the quota reservation was sized against. An
|
|
// object bigger than that has been billed for less than it costs.
|
|
it('cancels when the stored object is larger than the declared size', async () => {
|
|
const upload = await seedUpload({ declaredSizeBytes: BigInt(1024) });
|
|
storageHolds(1025);
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: 'Uploaded video size does not match upload request',
|
|
status: 400,
|
|
});
|
|
expect((await sessionOf(upload.sessionId)).status).toBe('CANCELLED');
|
|
});
|
|
|
|
it('accepts an object exactly the declared size', async () => {
|
|
const upload = await seedUpload({ declaredSizeBytes: BigInt(1024) });
|
|
storageHolds(1024);
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it('accepts an object smaller than declared, because compression is allowed to win', async () => {
|
|
const upload = await seedUpload({ declaredSizeBytes: BigInt(4096) });
|
|
storageHolds(10);
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.ok === true && result.sizeBytes).toBe(BigInt(10));
|
|
});
|
|
|
|
it('cancels when the header bytes cannot be read at all', async () => {
|
|
const upload = await seedUpload();
|
|
vi.mocked(readVideoObjectBytes).mockResolvedValue(null);
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: 'Uploaded file is not a valid video',
|
|
status: 400,
|
|
});
|
|
expect((await sessionOf(upload.sessionId)).status).toBe('CANCELLED');
|
|
});
|
|
|
|
// Content-type is caller-controlled, so the first 64 bytes are the only thing
|
|
// that decides whether this is really a video. A renamed .exe stops here.
|
|
it('cancels when the header bytes are not a known container', async () => {
|
|
const upload = await seedUpload();
|
|
storageHolds(1024, bytesOf(0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00, 0, 0, 0, 0));
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result.ok === false && result.error).toBe('Uploaded file is not a valid video');
|
|
expect(vi.mocked(deleteVideoObject)).toHaveBeenCalledWith(upload.objectKey);
|
|
});
|
|
|
|
it('cancels when the object is too short to carry a signature', async () => {
|
|
const upload = await seedUpload();
|
|
storageHolds(1024, bytesOf(0x66, 0x74, 0x79));
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result.ok === false && result.error).toBe('Uploaded file is not a valid video');
|
|
});
|
|
|
|
it.each([
|
|
['an mp4 ftyp box', mp4Header()],
|
|
['a matroska EBML header', bytesOf(0x1a, 0x45, 0xdf, 0xa3)],
|
|
['an Ogg page header', bytesOf(0x4f, 0x67, 0x67, 0x53)],
|
|
['a RIFF AVI header', bytesOf(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x41, 0x56, 0x49, 0x20)],
|
|
])('accepts %s', async (_label, header) => {
|
|
const upload = await seedUpload();
|
|
storageHolds(1024, header);
|
|
|
|
expect((await finalize(upload)).ok).toBe(true);
|
|
});
|
|
|
|
// A RIFF container that is not AVI (a .wav, say) has the same first four
|
|
// bytes and must not slip through on the prefix alone.
|
|
it('cancels a RIFF container that is not AVI', async () => {
|
|
const upload = await seedUpload();
|
|
storageHolds(1024, bytesOf(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x41, 0x56, 0x45));
|
|
|
|
expect((await finalize(upload)).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('finalizeR2VideoUpload success', () => {
|
|
it('returns the proxy URLs, the session and the billing subject', async () => {
|
|
const billed = await createUser();
|
|
const upload = await seedUpload({ declaredSizeBytes: BigInt(8192), billedUserId: billed.id });
|
|
storageHolds(7000);
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.sizeBytes).toBe(BigInt(7000));
|
|
expect(result.objectKey).toBe(upload.objectKey);
|
|
expect(result.proxyUrl).toBe(upload.proxyUrl);
|
|
expect(result.sessionId).toBe(upload.sessionId);
|
|
expect(result.reservationId).toBeNull();
|
|
expect(result.billedUserId).toBe(billed.id);
|
|
expect(result.thumbnailObjectKey).toBe(upload.thumbnailObjectKey);
|
|
expect(result.thumbnailProxyUrl).toBe(
|
|
`/api/upload/image/${upload.thumbnailObjectKey.slice('images/'.length)}`
|
|
);
|
|
});
|
|
|
|
// Finalisation validates; it does not consume. The caller writes the row and
|
|
// then marks the session FINALIZED, so a success here must leave the session
|
|
// exactly as it found it.
|
|
it('leaves the session INITIATED and deletes nothing', async () => {
|
|
const upload = await seedUpload();
|
|
|
|
const result = await finalize(upload);
|
|
|
|
expect(result.ok).toBe(true);
|
|
const session = await db.videoUploadSession.findUniqueOrThrow({
|
|
where: { id: upload.sessionId },
|
|
});
|
|
expect(session.status).toBe('INITIATED');
|
|
expect(session.consumedAt).toBeNull();
|
|
expect(vi.mocked(deleteVideoObject)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(deleteR2Object)).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('carries the reservation id through so the caller can release it', async () => {
|
|
const scenario = await seedProject();
|
|
const reservation = await db.uploadReservation.create({
|
|
data: {
|
|
billedUserId: scenario.owner.id,
|
|
sizeBytes: BigInt(4096),
|
|
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
|
|
},
|
|
});
|
|
const fileId = randomUUID();
|
|
const objectKey = `videos/${fileId}.mp4`;
|
|
const thumbnailObjectKey = `images/${fileId}.jpg`;
|
|
const uploadJti = randomUUID();
|
|
const session = await createR2UploadSession({
|
|
userId: scenario.owner.id,
|
|
projectId: scenario.project.id,
|
|
billedUserId: scenario.owner.id,
|
|
objectKey,
|
|
thumbnailObjectKey,
|
|
declaredSizeBytes: BigInt(4096),
|
|
contentType: 'video/mp4',
|
|
reservationId: reservation.id,
|
|
uploadJti,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
});
|
|
|
|
const result = await finalizeR2VideoUpload({
|
|
userId: scenario.owner.id,
|
|
projectId: scenario.project.id,
|
|
videoUrl: `/api/upload/video/${fileId}.mp4`,
|
|
objectKey,
|
|
uploadToken: createR2UploadToken({
|
|
userId: scenario.owner.id,
|
|
projectId: scenario.project.id,
|
|
objectKey,
|
|
sessionId: session.id,
|
|
tokenId: uploadJti,
|
|
thumbnailObjectKey,
|
|
}),
|
|
});
|
|
|
|
expect(result.ok === true && result.reservationId).toBe(reservation.id);
|
|
});
|
|
});
|