Files
OpenFrame/tests/api/assets-authz.test.ts
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

672 lines
25 KiB
TypeScript

// Authorization tests for the /api/videos/[videoId]/assets/* family, from callers
// who are signed in but not entitled.
//
// Every route in this family authorizes through one helper,
// `getVideoAssetAccessContext()` in lib/video-assets.ts, and then reads one of
// three flags off it: `hasViewAccess` to list, `canUploadAssets` to write, and
// `canDownloadAssets` to export. Before this file the only thing standing behind
// those flags was the anonymous sweep in tests/api/auth-matrix.test.ts, so
// collapsing all three onto `hasViewAccess`, or returning a context that is
// simply `{ hasViewAccess: true, canUploadAssets: true, ... }` for any signed-in
// caller, would not have failed a single test in the suite.
//
// Two details make these cases land on the guard rather than short of it.
//
// - Each route checks the access context *before* it parses the body. So an
// unauthorized caller gets 403 and an authorized caller sending the same
// payload gets a 400 from the validation underneath. The positive controls
// below deliberately stop on that 400: it is a status no unauthorized caller
// can reach, which is what makes the 403 next to it mean something.
//
// - The assets are YOUTUBE-provider rows. Deleting an R2 or Bunny asset sends
// the handler off to object storage, and downloading one proxies the bytes;
// a YouTube asset exercises the identical authorization path with no network
// underneath it.
import { describe, expect, it } from 'vitest';
import type { Project, User, Video, VideoAsset, Workspace } from '@prisma/client';
import { db } from '@/lib/db';
import { GET as listAssets, POST as createAsset } from '@/app/api/videos/[videoId]/assets/route';
import { DELETE as deleteAsset } from '@/app/api/videos/[videoId]/assets/[assetId]/route';
import { GET as downloadAsset } from '@/app/api/videos/[videoId]/assets/[assetId]/download/route';
import { POST as initAssetBunnyUpload } from '@/app/api/videos/[videoId]/assets/bunny-init/route';
import { POST as initAssetR2Upload } from '@/app/api/videos/[videoId]/assets/r2-init/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { signedInAs } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createExpiredUser,
createUser,
createVideo,
createVideoAsset,
nextSeq,
seedProject,
} from '../factories';
const SEEDED_ASSET_NAME = 'Seeded b-roll';
interface AssetFixture {
owner: User;
workspace: Workspace;
project: Project;
video: Video;
/** Uploaded by the project owner, so a COMMENTATOR is not its author. */
asset: VideoAsset;
}
async function seedAsset(
input: { allowDownloads: boolean; ownerUser?: User } = { allowDownloads: false }
): Promise<AssetFixture> {
const { owner, workspace, project } = await seedProject({
ownerUser: input.ownerUser,
visibility: 'PRIVATE',
allowDownloads: input.allowDownloads,
});
const video = await createVideo({ projectId: project.id, title: 'Video with assets' });
const asset = await createVideoAsset({
videoId: video.id,
billedUserId: owner.id,
kind: 'VIDEO',
provider: 'YOUTUBE',
displayName: SEEDED_ASSET_NAME,
sourceUrl: `https://www.youtube.com/watch?v=asset${nextSeq()}`,
providerVideoId: `asset-provider-${nextSeq()}`,
uploadedByUserId: owner.id,
});
return { owner, workspace, project, video, asset };
}
function assetsUrl(videoId: string): string {
return `/api/videos/${videoId}/assets`;
}
function assetUrl(videoId: string, assetId: string): string {
return `${assetsUrl(videoId)}/${assetId}`;
}
// ---------------------------------------------------------------------------
// GET /api/videos/[videoId]/assets
// ---------------------------------------------------------------------------
describe('GET /api/videos/[videoId]/assets', () => {
it('returns 403 to a signed-in stranger with their own unrelated workspace', async () => {
const fixture = await seedAsset();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(listAssets, apiRequest(assetsUrl(fixture.video.id)), {
videoId: fixture.video.id,
});
expect(response.status).toBe(403);
});
it('returns 403 to a project COMMENTATOR once the workspace owner loses billing', async () => {
const expiredOwner = await createExpiredUser();
const fixture = await seedAsset({ allowDownloads: true, ownerUser: expiredOwner });
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(listAssets, apiRequest(assetsUrl(fixture.video.id)), {
videoId: fixture.video.id,
});
expect(response.status).toBe(403);
});
it('lets a project COMMENTATOR list the assets', async () => {
const fixture = await seedAsset();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(listAssets, apiRequest(assetsUrl(fixture.video.id)), {
videoId: fixture.video.id,
});
expect(response.status).toBe(200);
const payload = await readData<{ assets: Array<{ id: string }> }>(response);
expect(payload.assets.map((asset) => asset.id)).toEqual([fixture.asset.id]);
});
});
// ---------------------------------------------------------------------------
// POST /api/videos/[videoId]/assets
// ---------------------------------------------------------------------------
// `canUploadAssets` is intentionally generous: a COMMENTATOR is meant to be able
// to attach a reference clip. Generous is not the same as open, and the cases
// below are the difference.
describe('POST /api/videos/[videoId]/assets', () => {
it('returns 403 to a signed-in stranger and writes no asset', async () => {
const fixture = await seedAsset();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
createAsset,
apiRequest(assetsUrl(fixture.video.id), {
body: { provider: 'YOUTUBE', sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
}),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(403);
expect(await db.videoAsset.count()).toBe(1);
});
it('returns 403 to a project COMMENTATOR once the workspace owner loses billing', async () => {
const expiredOwner = await createExpiredUser();
const fixture = await seedAsset({ allowDownloads: false, ownerUser: expiredOwner });
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
createAsset,
apiRequest(assetsUrl(fixture.video.id), {
body: { provider: 'YOUTUBE', sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
}),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(403);
expect(await db.videoAsset.count()).toBe(1);
});
// The IDOR shape: a caller who legitimately uploads assets to their own video,
// aiming the same request at a video id out of another workspace.
it('returns 403 for a video id belonging to another workspace', async () => {
const mine = await seedAsset();
const theirs = await seedAsset();
signedInAs(mine.owner);
const response = await callRoute(
createAsset,
apiRequest(assetsUrl(theirs.video.id), {
body: { provider: 'YOUTUBE', sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
}),
{ videoId: theirs.video.id }
);
expect(response.status).toBe(403);
expect(await db.videoAsset.count({ where: { videoId: theirs.video.id } })).toBe(1);
});
// The positive control. The access check runs before the body is parsed, so an
// authorized COMMENTATOR sending a deliberately bogus provider gets the 400
// from the validation underneath. 400 is a status the three refusals above
// cannot produce, which is what proves they came from the guard.
it('gets a project COMMENTATOR past the access check and onto body validation', async () => {
const fixture = await seedAsset();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
createAsset,
apiRequest(assetsUrl(fixture.video.id), { body: { provider: 'NOT_A_REAL_PROVIDER' } }),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('Invalid provider');
expect(await db.videoAsset.count()).toBe(1);
});
// And the same probe from the stranger, to show the ordering is real: identical
// body, identical URL, and the guard answers first.
it('still returns 403 to a stranger sending the same invalid body', async () => {
const fixture = await seedAsset();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
createAsset,
apiRequest(assetsUrl(fixture.video.id), { body: { provider: 'NOT_A_REAL_PROVIDER' } }),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// DELETE /api/videos/[videoId]/assets/[assetId]
// ---------------------------------------------------------------------------
// Two gates in sequence: `canUploadAssets` to be in the room at all, then
// `canDeleteAssetForViewer` which lets a COMMENTATOR remove only what they
// uploaded themselves. Both need their own negative case, because collapsing the
// second one is invisible from outside unless a test actually seeds an asset that
// belongs to somebody else.
describe('DELETE /api/videos/[videoId]/assets/[assetId]', () => {
it('returns 403 to a signed-in stranger and keeps the asset', async () => {
const fixture = await seedAsset();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(403);
expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
});
// The second gate. This caller is a legitimate member who may upload assets of
// their own; what they may not do is delete the owner's.
it("returns 403 when a project COMMENTATOR deletes somebody else's asset", async () => {
const fixture = await seedAsset();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(403);
expect(await readError(response)).toContain('only delete assets you uploaded');
expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
});
it("returns 403 when a workspace COMMENTATOR deletes the owner's asset", async () => {
const fixture = await seedAsset();
const workspaceCommentator = await createUser();
await addWorkspaceMember({
workspaceId: fixture.workspace.id,
userId: workspaceCommentator.id,
role: 'COMMENTATOR',
});
signedInAs(workspaceCommentator);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(403);
expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
});
it('returns 403 to the owner once their own billing access has lapsed', async () => {
const expiredOwner = await createExpiredUser();
const fixture = await seedAsset({ allowDownloads: false, ownerUser: expiredOwner });
signedInAs(expiredOwner);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(403);
expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
});
// Identifier substitution against a route the caller does legitimately reach:
// their own videoId in the path, somebody else's assetId in the query. The
// lookup pairs the two, so it misses.
it('returns 404 for a foreign asset id pasted onto my own video', async () => {
const mine = await seedAsset();
const theirs = await seedAsset();
signedInAs(mine.owner);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(mine.video.id, theirs.asset.id), { method: 'DELETE' }),
{ videoId: mine.video.id, assetId: theirs.asset.id }
);
expect(response.status).toBe(404);
expect(await db.videoAsset.count({ where: { id: theirs.asset.id } })).toBe(1);
expect(await db.videoAsset.count({ where: { id: mine.asset.id } })).toBe(1);
});
// The matching pair with both foreign ids, which is the request an attacker who
// has read an id out of a shared link would actually send. Here the row is
// found, so the refusal has to come from the access context.
it('returns 403 for a foreign asset reached through its own foreign video id', async () => {
const mine = await seedAsset();
const theirs = await seedAsset();
signedInAs(mine.owner);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(theirs.video.id, theirs.asset.id), { method: 'DELETE' }),
{ videoId: theirs.video.id, assetId: theirs.asset.id }
);
expect(response.status).toBe(403);
expect(await db.videoAsset.count({ where: { id: theirs.asset.id } })).toBe(1);
});
it('lets the project owner delete the asset', async () => {
const fixture = await seedAsset();
signedInAs(fixture.owner);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(200);
expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(0);
});
// The positive control for the second gate specifically: same role, same route,
// and the only thing that changed is who uploaded the row.
it('lets a project COMMENTATOR delete an asset they uploaded themselves', async () => {
const fixture = await seedAsset();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
const own = await createVideoAsset({
videoId: fixture.video.id,
billedUserId: fixture.owner.id,
kind: 'VIDEO',
provider: 'YOUTUBE',
displayName: 'Uploaded by the commentator',
sourceUrl: `https://www.youtube.com/watch?v=own${nextSeq()}`,
uploadedByUserId: commentator.id,
});
signedInAs(commentator);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(fixture.video.id, own.id), { method: 'DELETE' }),
{ videoId: fixture.video.id, assetId: own.id }
);
expect(response.status).toBe(200);
expect(await db.videoAsset.count({ where: { id: own.id } })).toBe(0);
// The owner's asset was never in scope and is still there.
expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
});
it("lets a project ADMIN delete the owner's asset", async () => {
const fixture = await seedAsset();
const admin = await createUser();
await addProjectMember({ projectId: fixture.project.id, userId: admin.id, role: 'ADMIN' });
signedInAs(admin);
const response = await callRoute(
deleteAsset,
apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(200);
expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(0);
});
});
// ---------------------------------------------------------------------------
// GET /api/videos/[videoId]/assets/[assetId]/download
// ---------------------------------------------------------------------------
// Two refusals with two different messages: `hasViewAccess` for people who should
// not see the video at all, and `canDownloadAssets` for members of a project whose
// owner has turned exports off. Both are pinned, because merging them would look
// like a tidy-up and would quietly hand the files to every viewer.
describe('GET /api/videos/[videoId]/assets/[assetId]/download', () => {
// 404 rather than 403 for a caller with no relationship to the project: a 403 confirms
// the id exists. The comment export route has always answered 404 for the identical
// shape, and the three download paths now agree.
it('returns 404 to a signed-in stranger', async () => {
const fixture = await seedAsset({ allowDownloads: true });
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
downloadAsset,
apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(404);
});
it('returns 403 to a project COMMENTATOR when downloads are disabled', async () => {
const fixture = await seedAsset({ allowDownloads: false });
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
downloadAsset,
apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(403);
expect(await readError(response)).toContain('Downloads are disabled');
});
// Positive control: same COMMENTATOR, same asset, allowDownloads flipped on.
// The request now clears both gates and stops on the provider check, a 400 that
// neither refusal above can produce.
it('gets the same COMMENTATOR past both gates once allowDownloads is on', async () => {
const fixture = await seedAsset({ allowDownloads: true });
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
downloadAsset,
apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('YouTube assets cannot be downloaded');
});
it('gets the owner past both gates even when allowDownloads is off', async () => {
const fixture = await seedAsset({ allowDownloads: false });
signedInAs(fixture.owner);
const response = await callRoute(
downloadAsset,
apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`),
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('YouTube assets cannot be downloaded');
});
it('returns 404 for a foreign asset id pasted onto my own video', async () => {
const mine = await seedAsset({ allowDownloads: true });
const theirs = await seedAsset({ allowDownloads: true });
signedInAs(mine.owner);
const response = await callRoute(
downloadAsset,
apiRequest(`${assetUrl(mine.video.id, theirs.asset.id)}/download`),
{ videoId: mine.video.id, assetId: theirs.asset.id }
);
expect(response.status).toBe(404);
});
it('returns 404 for a foreign asset reached through its own foreign video id', async () => {
const mine = await seedAsset({ allowDownloads: true });
const theirs = await seedAsset({ allowDownloads: true });
signedInAs(mine.owner);
const response = await callRoute(
downloadAsset,
apiRequest(`${assetUrl(theirs.video.id, theirs.asset.id)}/download`),
{ videoId: theirs.video.id, assetId: theirs.asset.id }
);
expect(response.status).toBe(404);
});
});
// ---------------------------------------------------------------------------
// The two upload-init routes
// ---------------------------------------------------------------------------
// Both hand out an upload credential, so a caller who gets through them can spend
// the workspace owner's storage quota. Direct uploads are unconfigured in the test
// environment, which is what gives each of these a positive control that stops one
// step past the guard without touching a provider.
describe('POST /api/videos/[videoId]/assets/r2-init', () => {
it('returns 403 to a signed-in stranger and reserves nothing', async () => {
const fixture = await seedAsset();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
initAssetR2Upload,
apiRequest(`${assetsUrl(fixture.video.id)}/r2-init`, {
body: { fileName: 'clip.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
}),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(403);
expect(await db.uploadReservation.count()).toBe(0);
});
it('returns 403 for a video id belonging to another workspace', async () => {
const mine = await seedAsset();
const theirs = await seedAsset();
signedInAs(mine.owner);
const response = await callRoute(
initAssetR2Upload,
apiRequest(`${assetsUrl(theirs.video.id)}/r2-init`, {
body: { fileName: 'clip.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
}),
{ videoId: theirs.video.id }
);
expect(response.status).toBe(403);
expect(await db.uploadReservation.count()).toBe(0);
});
it('gets a project COMMENTATOR past the access check onto the disabled-feature check', async () => {
const fixture = await seedAsset();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
initAssetR2Upload,
apiRequest(`${assetsUrl(fixture.video.id)}/r2-init`, {
body: { fileName: 'clip.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
}),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('S3 video uploads are disabled');
expect(await db.uploadReservation.count()).toBe(0);
});
});
describe('POST /api/videos/[videoId]/assets/bunny-init', () => {
it('returns 403 to a signed-in stranger', async () => {
const fixture = await seedAsset();
await seedProject();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
initAssetBunnyUpload,
apiRequest(`${assetsUrl(fixture.video.id)}/bunny-init`, { body: { title: 'A clip' } }),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(403);
});
it('returns 403 to a project COMMENTATOR once the workspace owner loses billing', async () => {
const expiredOwner = await createExpiredUser();
const fixture = await seedAsset({ allowDownloads: false, ownerUser: expiredOwner });
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
initAssetBunnyUpload,
apiRequest(`${assetsUrl(fixture.video.id)}/bunny-init`, { body: { title: 'A clip' } }),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(403);
});
it('gets a project COMMENTATOR past the access check onto the disabled-feature check', async () => {
const fixture = await seedAsset();
const commentator = await createUser();
await addProjectMember({
projectId: fixture.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
initAssetBunnyUpload,
apiRequest(`${assetsUrl(fixture.video.id)}/bunny-init`, { body: { title: 'A clip' } }),
{ videoId: fixture.video.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toContain('Direct uploads are disabled');
});
});