Files
OpenFrame/tests/api/search-scope.test.ts
T
yusufipk 0187db5dc7 test: close the coverage gaps the first round left
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.
2026-07-26 13:25:11 +07:00

346 lines
14 KiB
TypeScript

// Result scoping for GET /api/search.
//
// The auth matrix already proves an anonymous caller gets 401, and beyond that
// there is nothing to test on the authorization axis: the route reads the
// caller's id straight off the session, so no "forbidden caller" exists. The
// question this file asks instead is a data-leak one, and nothing asserted it
// before: does the search actually restrict its rows to the caller's own
// tenants?
//
// It matters because search is the one endpoint that queries `project`,
// `workspace` and `video` globally rather than through a project id in the URL.
// Every filter is inline in the handler, none of it goes through
// `checkProjectAccess()`, and there is no shared helper that a regression would
// have to break twice. Dropping the `projectAccessFilter` clause from the video
// query would turn the search box into a list of every video title in the
// database, and until this file nothing would have noticed.
//
// Every test uses a term that appears in exactly one tenant's rows, so a hit is
// unambiguous. Each refusal is paired with the same query run by a caller who
// should see it, which is what rules out an empty result that came from the
// query never matching anything in the first place.
import { describe, expect, it } from 'vitest';
import { GET as search } from '@/app/api/search/route';
import { GET as listProjects } from '@/app/api/projects/route';
import { apiRequest, callRoute, readData } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createExpiredUser,
createProject,
createUser,
createVideo,
createWorkspace,
nextSeq,
seedProject,
} from '../factories';
interface SearchResults {
projects: Array<{ id: string; name: string }>;
workspaces: Array<{ id: string; name: string }>;
videos: Array<{ id: string; title: string }>;
}
async function searchFor(term: string): Promise<SearchResults> {
const response = await callRoute(
search,
apiRequest('/api/search', { searchParams: { q: term } })
);
expect(response.status).toBe(200);
return readData<SearchResults>(response);
}
/** A term that cannot collide with a factory default name or another test's rows. */
function uniqueTerm(): string {
return `Zephyrine${nextSeq()}`;
}
// ---------------------------------------------------------------------------
// Cross-tenant leakage
// ---------------------------------------------------------------------------
describe('GET /api/search does not reach into another tenant', () => {
it('returns nothing to an anonymous caller', async () => {
const term = uniqueTerm();
const { project } = await seedProject({ projectName: `${term} project` });
await createVideo({ projectId: project.id, title: `${term} cut` });
signedOut();
const response = await callRoute(
search,
apiRequest('/api/search', { searchParams: { q: term } })
);
expect(response.status).toBe(401);
});
it('hides a stranger project matching the term by name', async () => {
const term = uniqueTerm();
await seedProject({ projectName: `${term} deliverables` });
const outsider = await createUser();
signedInAs(outsider);
const results = await searchFor(term);
expect(results.projects).toEqual([]);
});
// The positive control for the case above. Same term, same row, and the only
// difference is who is asking, so an empty result cannot be blamed on the
// query failing to match.
it('shows that same project to its owner', async () => {
const term = uniqueTerm();
const { owner } = await seedProject({ projectName: `${term} deliverables` });
signedInAs(owner);
const results = await searchFor(term);
expect(results.projects.map((project) => project.name)).toEqual([`${term} deliverables`]);
});
it('hides a stranger project matching the term only in its description', async () => {
const term = uniqueTerm();
const scenario = await seedProject();
await createProject({
ownerId: scenario.owner.id,
workspaceId: scenario.workspace.id,
name: 'Unremarkable name',
description: `Rough cut for the ${term} campaign`,
});
const outsider = await createUser();
signedInAs(outsider);
const results = await searchFor(term);
expect(results.projects).toEqual([]);
});
it('shows the description match to the project owner', async () => {
const term = uniqueTerm();
const scenario = await seedProject();
await createProject({
ownerId: scenario.owner.id,
workspaceId: scenario.workspace.id,
name: 'Unremarkable name',
description: `Rough cut for the ${term} campaign`,
});
signedInAs(scenario.owner);
const results = await searchFor(term);
expect(results.projects).toHaveLength(1);
});
// The headline case from the gap inventory: a video title is the most
// sensitive string in this product's search index, because it is usually a
// client name or an unannounced campaign.
it('hides a video in a stranger project whose title matches the term', async () => {
const term = uniqueTerm();
const { project } = await seedProject();
await createVideo({ projectId: project.id, title: `${term} launch cut` });
// The outsider owns a real tenant of their own, so nothing about the request
// is unusual: they simply have no relationship to the project holding the hit.
await seedProject();
const outsider = await createUser();
signedInAs(outsider);
const results = await searchFor(term);
expect(results.videos).toEqual([]);
});
it('shows that same video to the owner of the project holding it', async () => {
const term = uniqueTerm();
const { owner, project } = await seedProject();
await createVideo({ projectId: project.id, title: `${term} launch cut` });
signedInAs(owner);
const results = await searchFor(term);
expect(results.videos.map((video) => video.title)).toEqual([`${term} launch cut`]);
});
it('hides a stranger workspace matching the term by name', async () => {
const term = uniqueTerm();
const stranger = await createUser();
await createWorkspace({ ownerId: stranger.id, name: `${term} Studio` });
const outsider = await createUser();
signedInAs(outsider);
const results = await searchFor(term);
expect(results.workspaces).toEqual([]);
});
it('shows that same workspace to its owner', async () => {
const term = uniqueTerm();
const stranger = await createUser();
await createWorkspace({ ownerId: stranger.id, name: `${term} Studio` });
signedInAs(stranger);
const results = await searchFor(term);
expect(results.workspaces.map((workspace) => workspace.name)).toEqual([`${term} Studio`]);
});
// Search is scoped by membership, not by visibility: `checkProjectAccess()`
// would let this caller open the project, but it does not surface in their
// search. Pinned because it is the one place the two rules deliberately differ,
// and because widening search to match the access check would be a real
// exposure of every public project's video titles.
it('hides a PUBLIC stranger project the caller has never joined', async () => {
const term = uniqueTerm();
const { project } = await seedProject({
visibility: 'PUBLIC',
projectName: `${term} open project`,
});
await createVideo({ projectId: project.id, title: `${term} open cut` });
const outsider = await createUser();
signedInAs(outsider);
const results = await searchFor(term);
expect(results.projects).toEqual([]);
expect(results.videos).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// The three ways in
// ---------------------------------------------------------------------------
// The access filter has three branches. Each one is exercised here, because a
// scoping test that only proves "strangers see nothing" would still pass if the
// filter had collapsed to `ownerId` and quietly stopped showing collaborators
// their own work.
describe('GET /api/search reaches everything the caller is entitled to', () => {
it('shows a project the caller was added to directly', async () => {
const term = uniqueTerm();
const { project } = await seedProject({ projectName: `${term} shared cut` });
const collaborator = await createUser();
await addProjectMember({
projectId: project.id,
userId: collaborator.id,
role: 'COMMENTATOR',
});
signedInAs(collaborator);
const results = await searchFor(term);
expect(results.projects.map((entry) => entry.id)).toEqual([project.id]);
});
it('shows videos in a project the caller was added to directly', async () => {
const term = uniqueTerm();
const { project } = await seedProject();
const video = await createVideo({ projectId: project.id, title: `${term} rough cut` });
const collaborator = await createUser();
await addProjectMember({ projectId: project.id, userId: collaborator.id });
signedInAs(collaborator);
const results = await searchFor(term);
expect(results.videos.map((entry) => entry.id)).toEqual([video.id]);
});
// A workspace member with no project membership row at all. This is the branch
// most likely to be dropped by accident, because the other two are obvious.
it('shows a project the caller reaches only through workspace membership', async () => {
const term = uniqueTerm();
const { workspace, project } = await seedProject({ projectName: `${term} workspace cut` });
const video = await createVideo({ projectId: project.id, title: `${term} workspace video` });
const workspaceMember = await createUser();
await addWorkspaceMember({ workspaceId: workspace.id, userId: workspaceMember.id });
signedInAs(workspaceMember);
const results = await searchFor(term);
expect(results.projects.map((entry) => entry.id)).toEqual([project.id]);
expect(results.videos.map((entry) => entry.id)).toEqual([video.id]);
});
it('shows a workspace the caller is a member of', async () => {
const term = uniqueTerm();
const stranger = await createUser();
const workspace = await createWorkspace({ ownerId: stranger.id, name: `${term} Studio` });
const member = await createUser();
await addWorkspaceMember({ workspaceId: workspace.id, userId: member.id });
signedInAs(member);
const results = await searchFor(term);
expect(results.workspaces.map((entry) => entry.id)).toEqual([workspace.id]);
});
// A project membership must not leak the enclosing workspace, which usually
// carries the agency's own name and its other clients.
it('does not show the enclosing workspace to a project-only member', async () => {
const term = uniqueTerm();
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id, name: `${term} Studio` });
const project = await createProject({
ownerId: owner.id,
workspaceId: workspace.id,
name: `${term} client project`,
});
const collaborator = await createUser();
await addProjectMember({ projectId: project.id, userId: collaborator.id });
signedInAs(collaborator);
const results = await searchFor(term);
expect(results.projects.map((entry) => entry.id)).toEqual([project.id]);
expect(results.workspaces).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Billing
// ---------------------------------------------------------------------------
// Pins current behaviour, and the behaviour is inconsistent. See the report
// accompanying this suite: GET /api/projects filters every row through
// `workspace.owner: buildBillingAccessWhereInput()`, and `checkProjectAccess()`
// makes `hasAccess` false the moment the workspace owner's billing lapses, so
// the project itself answers 403. /api/search applies no billing filter at all
// and keeps returning the names. Changing that means changing this test.
describe('GET /api/search and lapsed billing', () => {
it('keeps returning a project whose workspace owner has lost billing access', async () => {
const term = uniqueTerm();
const expiredOwner = await createExpiredUser();
await seedProject({ ownerUser: expiredOwner, projectName: `${term} lapsed project` });
signedInAs(expiredOwner);
const results = await searchFor(term);
expect(results.projects.map((entry) => entry.name)).toEqual([`${term} lapsed project`]);
});
// The same caller, the same row, through the list endpoint instead. This is
// the contrast that makes the case above a finding rather than a preference.
it('is hidden from GET /api/projects for the same caller and the same row', async () => {
const expiredOwner = await createExpiredUser();
await seedProject({ ownerUser: expiredOwner, projectName: 'Lapsed project' });
signedInAs(expiredOwner);
const response = await callRoute(listProjects, apiRequest('/api/projects'));
expect(response.status).toBe(200);
const { projects } = await readData<{ projects: Array<{ id: string }> }>(response);
expect(projects).toEqual([]);
});
it('keeps returning a video title from a lapsed workspace to a collaborator', async () => {
const term = uniqueTerm();
const expiredOwner = await createExpiredUser();
const { project } = await seedProject({ ownerUser: expiredOwner });
await createVideo({ projectId: project.id, title: `${term} lapsed cut` });
const collaborator = await createUser();
await addProjectMember({ projectId: project.id, userId: collaborator.id });
signedInAs(collaborator);
const results = await searchFor(term);
expect(results.videos.map((entry) => entry.title)).toEqual([`${term} lapsed cut`]);
});
});