Files
OpenFrame/tests/api/projects.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

741 lines
25 KiB
TypeScript

import { beforeEach, describe, expect, it } from 'vitest';
import { db } from '@/lib/db';
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
import { GET as listProjects, POST as createProjectRoute } from '@/app/api/projects/route';
import {
DELETE as deleteProject,
GET as getProject,
PATCH as patchProject,
} from '@/app/api/projects/[projectId]/route';
import { apiRequest, callRoute, readData, readJson } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createExpiredUser,
createProject,
createUser,
createVideo,
createWorkspace,
seedProject,
} from '../factories';
interface ListedProject {
id: string;
name: string;
}
async function listFor(userId: string, query = ''): Promise<Response> {
signedInAs({ id: userId });
return callRoute(listProjects, apiRequest(`/api/projects${query}`));
}
describe('GET /api/projects', () => {
it('returns 401 without a session', async () => {
signedOut();
const response = await callRoute(listProjects, apiRequest('/api/projects'));
expect(response.status).toBe(401);
});
it.each([
['page=0', 'page 0 is below the minimum'],
['page=1001', 'page 1001 is past MAX_PAGE'],
['page=1.5', 'a non-integer page'],
['page=abc', 'an unparseable page'],
['limit=0', 'limit 0 is below the minimum'],
['limit=101', 'limit 101 is past MAX_LIMIT'],
['page=1000&limit=100', 'an offset of 99900 is past MAX_OFFSET'],
])('rejects ?%s with 400 (%s)', async (query, label) => {
const user = await createUser();
const response = await listFor(user.id, `?${query}`);
expect(response.status, label).toBe(400);
});
it('accepts the boundary values page=1000&limit=10 and limit=100', async () => {
const user = await createUser();
expect((await listFor(user.id, '?page=1000&limit=10')).status).toBe(200);
expect((await listFor(user.id, '?limit=100')).status).toBe(200);
});
it('lists projects the caller owns, with pagination metadata', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'Second' });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'Third' });
const response = await listFor(owner.id, '?page=2&limit=2');
const payload = await readJson<{
data: { projects: ListedProject[] };
meta: { page: number; limit: number; total: number; totalPages: number };
}>(response);
expect(response.status).toBe(200);
expect(payload.data.projects).toHaveLength(1);
expect(payload.meta).toEqual({ page: 2, limit: 2, total: 3, totalPages: 2 });
});
it('does not list projects belonging to another user', async () => {
const stranger = await seedProject();
const caller = await createUser();
const projects = await readData<{ projects: ListedProject[] }>(await listFor(caller.id));
expect(projects.projects).toEqual([]);
// Sanity check that the arrangement was real and the empty result is about
// the filter rather than about an empty database.
expect(await db.project.count()).toBe(1);
expect(stranger.project.id).toBeTruthy();
});
// This is the buildBillingAccessWhereInput() guard in the list filter. Both
// halves are here on purpose: without the positive control, deleting the
// clause entirely would still leave the negative test passing for the wrong
// reason.
it('hides a project whose workspace owner has lost billing access, even from its owner', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
await createProject({ ownerId: expiredOwner.id, workspaceId: workspace.id });
const payload = await readJson<{
data: { projects: ListedProject[] };
meta: { total: number };
}>(await listFor(expiredOwner.id));
expect(payload.data.projects).toEqual([]);
expect(payload.meta.total).toBe(0);
});
it('lists the same project once the owner has billing access again', async () => {
const owner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: owner.id });
const project = await createProject({ ownerId: owner.id, workspaceId: workspace.id });
await db.user.update({
where: { id: owner.id },
data: { trialEndsAt: new Date(Date.now() + 24 * 60 * 60 * 1000) },
});
const projects = await readData<{ projects: ListedProject[] }>(await listFor(owner.id));
expect(projects.projects.map((entry) => entry.id)).toEqual([project.id]);
});
it('hides a project from a member when the workspace owner has lost billing access', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
const project = await createProject({
ownerId: expiredOwner.id,
workspaceId: workspace.id,
});
const member = await createUser();
await addProjectMember({ projectId: project.id, userId: member.id });
const projects = await readData<{ projects: ListedProject[] }>(await listFor(member.id));
expect(projects.projects).toEqual([]);
});
it('lists a workspace member the projects of that workspace', async () => {
const scenario = await seedProject();
const member = await createUser();
await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: member.id });
const projects = await readData<{ projects: ListedProject[] }>(await listFor(member.id));
expect(projects.projects.map((entry) => entry.id)).toEqual([scenario.project.id]);
});
// The workspace-membership branch of the OR used to be dropped as soon as ?workspaceId
// was supplied, so filtering by their own workspace showed a member an empty list while
// the unfiltered call returned the same project.
it('still lists workspace-member projects when ?workspaceId is supplied', async () => {
const scenario = await seedProject();
const member = await createUser();
await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: member.id });
const unfiltered = await readData<{ projects: ListedProject[] }>(await listFor(member.id));
const filtered = await readData<{ projects: ListedProject[] }>(
await listFor(member.id, `?workspaceId=${scenario.workspace.id}`)
);
expect(unfiltered.projects.map((entry) => entry.id)).toEqual([scenario.project.id]);
expect(filtered.projects.map((entry) => entry.id)).toEqual([scenario.project.id]);
});
it('scopes ?workspaceId to that workspace for an owner of several', async () => {
const owner = await createUser();
const first = await createWorkspace({ ownerId: owner.id });
const second = await createWorkspace({ ownerId: owner.id });
const wanted = await createProject({ ownerId: owner.id, workspaceId: first.id });
await createProject({ ownerId: owner.id, workspaceId: second.id });
const projects = await readData<{ projects: ListedProject[] }>(
await listFor(owner.id, `?workspaceId=${first.id}`)
);
expect(projects.projects.map((entry) => entry.id)).toEqual([wanted.id]);
});
});
describe('POST /api/projects', () => {
it('returns 401 without a session', async () => {
signedOut();
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'X', workspaceId: 'y' } })
);
expect(response.status).toBe(401);
expect(await db.project.count()).toBe(0);
});
it.each([
[{ workspaceId: 'w' }, 'a missing name'],
[{ name: ' ', workspaceId: 'w' }, 'a blank name'],
[{ name: 42, workspaceId: 'w' }, 'a non-string name'],
[{ name: 'Valid' }, 'a missing workspaceId'],
[{ name: 'Valid', workspaceId: 17 }, 'a non-string workspaceId'],
])('rejects %j with 400 (%s)', async (body, label) => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(createProjectRoute, apiRequest('/api/projects', { body }));
expect(response.status, label).toBe(400);
expect(await db.project.count()).toBe(0);
});
it('returns 404 for a workspace that does not exist', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Orphan', workspaceId: 'no-such-workspace' } })
);
expect(response.status).toBe(404);
});
it('returns 403 for a workspace COMMENTATOR', async () => {
const scenario = await seedProject();
const commentator = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: { name: 'Sneaky', workspaceId: scenario.workspace.id },
})
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('returns 403 when the workspace owner has lost billing access', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
signedInAs(expiredOwner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Blocked', workspaceId: workspace.id } })
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(0);
});
it('creates the project with the five default comment tags', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: { name: 'My Project', description: ' spaced ', workspaceId: workspace.id },
})
);
expect(response.status).toBe(201);
const stored = await db.project.findFirstOrThrow({ include: { commentTags: true } });
expect(stored.name).toBe('My Project');
expect(stored.description).toBe('spaced');
expect(stored.slug).toBe('my-project');
expect(stored.visibility).toBe('PRIVATE');
expect(stored.allowDownloads).toBe(false);
expect(stored.workspaceId).toBe(workspace.id);
expect(stored.commentTags.map((tag) => tag.name).sort()).toEqual(
DEFAULT_COMMENT_TAGS.map((tag) => tag.name).sort()
);
expect(stored.commentTags.map((tag) => tag.color).sort()).toEqual(
DEFAULT_COMMENT_TAGS.map((tag) => tag.color).sort()
);
});
// The row's owner is the workspace owner, never the caller: ownership drives
// billing, and a workspace admin creating a project must not shift the bill.
it('assigns the workspace owner as project owner when a workspace ADMIN creates it', async () => {
const workspaceOwner = await createUser();
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
const admin = await createUser();
await addWorkspaceMember({ workspaceId: workspace.id, userId: admin.id, role: 'ADMIN' });
signedInAs(admin);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: { name: 'Admin Project', workspaceId: workspace.id },
})
);
expect(response.status).toBe(201);
const stored = await db.project.findFirstOrThrow();
expect(stored.ownerId).toBe(workspaceOwner.id);
expect(stored.ownerId).not.toBe(admin.id);
});
it('ignores an ownerId, slug and id supplied by the caller', async () => {
const owner = await createUser();
const impostor = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: {
name: 'Clean Slate',
workspaceId: workspace.id,
id: 'attacker-chosen-id',
ownerId: impostor.id,
slug: 'attacker-chosen-slug',
allowDownloads: true,
},
})
);
expect(response.status).toBe(201);
const stored = await db.project.findFirstOrThrow();
expect(stored.id).not.toBe('attacker-chosen-id');
expect(stored.ownerId).toBe(owner.id);
expect(stored.slug).toBe('clean-slate');
expect(stored.allowDownloads).toBe(false);
});
it('gives two projects with the same name distinct slugs', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
signedInAs(owner);
for (let index = 0; index < 3; index += 1) {
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Same Name', workspaceId: workspace.id } })
);
expect(response.status).toBe(201);
}
const slugs = (await db.project.findMany({ select: { slug: true } })).map((row) => row.slug);
expect(slugs.sort()).toEqual(['same-name', 'same-name-1', 'same-name-2']);
});
it('honours an explicit PUBLIC visibility', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: { name: 'Open', workspaceId: workspace.id, visibility: 'PUBLIC' },
})
);
expect(response.status).toBe(201);
expect((await db.project.findFirstOrThrow()).visibility).toBe('PUBLIC');
});
});
describe('GET /api/projects/[projectId]', () => {
it('returns 404 for an unknown project', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(getProject, apiRequest('/api/projects/nope'), {
projectId: 'nope',
});
expect(response.status).toBe(404);
});
it('returns 403 for a signed-in non-member on a PRIVATE project', async () => {
const scenario = await seedProject({ visibility: 'PRIVATE' });
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
});
it('returns 403 for a signed-in non-member on an INVITE project', async () => {
const scenario = await seedProject({ visibility: 'INVITE' });
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
});
it('returns 200 to an anonymous caller on a PUBLIC project', async () => {
const scenario = await seedProject({ visibility: 'PUBLIC' });
signedOut();
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
});
it('returns 403 to the owner once their own billing access has lapsed', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
const project = await createProject({ ownerId: expiredOwner.id, workspaceId: workspace.id });
signedInAs(expiredOwner);
const response = await callRoute(getProject, apiRequest(`/api/projects/${project.id}`), {
projectId: project.id,
});
expect(response.status).toBe(403);
});
it.each([['limit=0'], ['limit=101'], ['offset=-1'], ['offset=10001'], ['offset=abc']])(
'rejects ?%s with 400',
async (query) => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}?${query}`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(400);
}
);
it('paginates the embedded video list with limit and offset', async () => {
const scenario = await seedProject();
for (let position = 0; position < 3; position += 1) {
await createVideo({ projectId: scenario.project.id, position });
}
signedInAs(scenario.owner);
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}?limit=2&offset=2`),
{ projectId: scenario.project.id }
);
const project = await readData<{ videos: Array<{ id: string }>; _count: { videos: number } }>(
response
);
expect(response.status).toBe(200);
expect(project.videos).toHaveLength(1);
expect(project._count.videos).toBe(3);
});
});
describe('PATCH /api/projects/[projectId]', () => {
it('returns 401 without a session', async () => {
const scenario = await seedProject();
signedOut();
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: { name: 'Renamed' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(401);
expect((await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } })).name).toBe(
scenario.project.name
);
});
it('returns 403 for a project COMMENTATOR', async () => {
const scenario = await seedProject();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: { name: 'Renamed by a commentator' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect((await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } })).name).toBe(
scenario.project.name
);
});
it('returns 403 for an unknown project rather than 404', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(
patchProject,
apiRequest('/api/projects/nope', { method: 'PATCH', body: { name: 'X' } }),
{ projectId: 'nope' }
);
expect(response.status).toBe(403);
});
it('lets a project ADMIN rename the project', async () => {
const scenario = await seedProject();
const admin = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: admin.id,
role: 'ADMIN',
});
signedInAs(admin);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: { name: ' Renamed ', description: ' new description ', allowDownloads: true },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
const stored = await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } });
expect(stored.name).toBe('Renamed');
expect(stored.description).toBe('new description');
expect(stored.allowDownloads).toBe(true);
});
it('lets a workspace ADMIN edit a project they are not a member of', async () => {
const scenario = await seedProject();
const workspaceAdmin = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: workspaceAdmin.id,
role: 'ADMIN',
});
signedInAs(workspaceAdmin);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: { visibility: 'PUBLIC' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
expect(
(await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } })).visibility
).toBe('PUBLIC');
});
it.each([
[{ name: '' }, 'an empty name'],
[{ name: 'x'.repeat(101) }, 'a name over 100 characters'],
[{ description: 'x'.repeat(1001) }, 'a description over 1000 characters'],
[{ description: 5 }, 'a non-string description'],
[{ visibility: 'SEMI_PRIVATE' }, 'an unknown visibility'],
[{ allowDownloads: 'yes' }, 'a non-boolean allowDownloads'],
])('rejects %j with 400 (%s)', async (body, label) => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'PATCH', body }),
{ projectId: scenario.project.id }
);
expect(response.status, label).toBe(400);
const stored = await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } });
expect(stored.name).toBe(scenario.project.name);
expect(stored.visibility).toBe(scenario.project.visibility);
});
it('ignores an ownerId and a workspaceId in the body', async () => {
const scenario = await seedProject();
const other = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: {
name: 'Still Mine',
ownerId: other.owner.id,
workspaceId: other.workspace.id,
},
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
const stored = await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } });
expect(stored.ownerId).toBe(scenario.owner.id);
expect(stored.workspaceId).toBe(scenario.workspace.id);
});
});
describe('DELETE /api/projects/[projectId]', () => {
beforeEach(() => {
signedOut();
});
it('returns 401 without a session', async () => {
const scenario = await seedProject();
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(401);
expect(await db.project.count()).toBe(1);
});
it('returns 404 for an unknown project', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(
deleteProject,
apiRequest('/api/projects/nope', { method: 'DELETE' }),
{ projectId: 'nope' }
);
expect(response.status).toBe(404);
});
// canDelete is deliberately narrower than canEdit: a project ADMIN may rename
// and configure the project but may not destroy it.
it('returns 403 for a project ADMIN', async () => {
const scenario = await seedProject();
const admin = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: admin.id, role: 'ADMIN' });
signedInAs(admin);
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('returns 403 for a workspace ADMIN who is not the workspace owner', async () => {
const scenario = await seedProject();
const workspaceAdmin = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: workspaceAdmin.id,
role: 'ADMIN',
});
signedInAs(workspaceAdmin);
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('deletes the project and cascades to its videos for the owner', async () => {
const scenario = await seedProject();
await createVideo({ projectId: scenario.project.id });
signedInAs(scenario.owner);
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
expect(await db.project.count()).toBe(0);
expect(await db.video.count()).toBe(0);
});
it('lets the workspace owner delete a project owned by someone else', async () => {
const workspaceOwner = await createUser();
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
const projectOwner = await createUser();
const project = await createProject({
ownerId: projectOwner.id,
workspaceId: workspace.id,
});
signedInAs(workspaceOwner);
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${project.id}`, { method: 'DELETE' }),
{ projectId: project.id }
);
expect(response.status).toBe(200);
expect(await db.project.count()).toBe(0);
});
});