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.
571 lines
20 KiB
TypeScript
571 lines
20 KiB
TypeScript
// Exercises lib/video-assets.ts directly.
|
|
//
|
|
// Two API suites (assets-authz, download-authz) already drive
|
|
// getVideoAssetAccessContext() through routes, but only ever at the granularity
|
|
// of a status code. That leaves the flags it computes indistinguishable from
|
|
// one another: a context that set all four booleans to `hasViewAccess` would
|
|
// pass every one of those tests. This file asserts on the flags themselves, and
|
|
// on the pure helpers around them that nothing else covers at all.
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import { createShareSessionValue, getShareSessionCookieName } from '@/lib/share-session';
|
|
import {
|
|
canDeleteAssetForViewer,
|
|
extractAudioFileNameFromProxyUrl,
|
|
extractAudioKeyFromProxyUrl,
|
|
extractImageFileNameFromProxyUrl,
|
|
extractImageKeyFromProxyUrl,
|
|
extractVideoFileNameFromProxyUrl,
|
|
extractVideoKeyFromProxyUrl,
|
|
getVideoAssetAccessContext,
|
|
mediaUrlToR2Key,
|
|
sanitizeAssetDisplayName,
|
|
SAFE_BUNNY_VIDEO_ID,
|
|
} from '@/lib/video-assets';
|
|
import { apiRequest } from '../helpers/request';
|
|
import { signedInAs, signedOut } from '../helpers/session';
|
|
import {
|
|
addProjectMember,
|
|
addWorkspaceMember,
|
|
createExpiredUser,
|
|
createShareLink,
|
|
createUser,
|
|
createVideo,
|
|
seedProject,
|
|
} from '../factories';
|
|
|
|
const IMAGE_URL = '/api/upload/image/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png';
|
|
const AUDIO_URL = '/api/upload/audio/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2.webm';
|
|
const VIDEO_URL = '/api/upload/video/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee3.mp4';
|
|
|
|
describe('sanitizeAssetDisplayName', () => {
|
|
it('keeps an ordinary name unchanged', () => {
|
|
expect(sanitizeAssetDisplayName('B-roll take 2.mp4', 'fallback')).toBe('B-roll take 2.mp4');
|
|
});
|
|
|
|
// Brackets and parentheses are stripped because the name is interpolated into
|
|
// Markdown-ish notification bodies, where they would change the rendering.
|
|
it('strips brackets and parentheses', () => {
|
|
expect(sanitizeAssetDisplayName('[click](http://evil.test) shot', 'fallback')).toBe(
|
|
'clickhttp://evil.test shot'
|
|
);
|
|
});
|
|
|
|
// Control characters are stripped before whitespace is collapsed, so a
|
|
// newline leaves no gap behind where it used to be.
|
|
it('strips control characters', () => {
|
|
expect(sanitizeAssetDisplayName('take\u00001\u007Fsecond\nthird', 'fallback')).toBe(
|
|
'take1secondthird'
|
|
);
|
|
});
|
|
|
|
it('collapses runs of whitespace and trims the ends', () => {
|
|
expect(sanitizeAssetDisplayName(' take two \t three ', 'fallback')).toBe(
|
|
'take two three'
|
|
);
|
|
});
|
|
|
|
it.each<[string | null | undefined, string]>([
|
|
[null, 'a null value'],
|
|
[undefined, 'an undefined value'],
|
|
['', 'an empty string'],
|
|
[' ', 'only whitespace'],
|
|
['[]()', 'only stripped characters'],
|
|
[42 as unknown as string, 'a non-string value'],
|
|
])('falls back for %s (%s)', (value) => {
|
|
expect(sanitizeAssetDisplayName(value, 'Comment Image')).toBe('Comment Image');
|
|
});
|
|
|
|
it('truncates at 200 characters', () => {
|
|
const result = sanitizeAssetDisplayName('x'.repeat(500), 'fallback');
|
|
|
|
expect(result).toHaveLength(200);
|
|
expect(result).toBe('x'.repeat(200));
|
|
});
|
|
});
|
|
|
|
describe('proxy URL extraction', () => {
|
|
it('derives the image key and file name from a canonical image URL', () => {
|
|
expect(extractImageKeyFromProxyUrl(IMAGE_URL)).toBe(
|
|
'images/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png'
|
|
);
|
|
expect(extractImageFileNameFromProxyUrl(IMAGE_URL)).toBe(
|
|
'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png'
|
|
);
|
|
});
|
|
|
|
it('derives the audio key and file name from a canonical audio URL', () => {
|
|
expect(extractAudioKeyFromProxyUrl(AUDIO_URL)).toBe(
|
|
'voice/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2.webm'
|
|
);
|
|
expect(extractAudioFileNameFromProxyUrl(AUDIO_URL)).toBe(
|
|
'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2.webm'
|
|
);
|
|
});
|
|
|
|
it('derives the video key and file name from a canonical video URL', () => {
|
|
expect(extractVideoKeyFromProxyUrl(VIDEO_URL)).toBe(
|
|
'videos/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee3.mp4'
|
|
);
|
|
expect(extractVideoFileNameFromProxyUrl(VIDEO_URL)).toBe(
|
|
'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee3.mp4'
|
|
);
|
|
});
|
|
|
|
// Each extractor is anchored on its own prefix, so a URL of one media type
|
|
// must not resolve through another type's extractor.
|
|
it('refuses a URL from a different media prefix', () => {
|
|
expect(extractImageKeyFromProxyUrl(AUDIO_URL)).toBeNull();
|
|
expect(extractAudioKeyFromProxyUrl(VIDEO_URL)).toBeNull();
|
|
expect(extractVideoKeyFromProxyUrl(IMAGE_URL)).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
['/api/upload/image/../../videos/live.mp4', 'a traversal segment'],
|
|
['https://evil.test/api/upload/image/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png', 'a host'],
|
|
['/api/upload/image/not-a-uuid.png', 'a non-uuid basename'],
|
|
['/api/upload/image/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png/extra', 'a trailing segment'],
|
|
['', 'an empty string'],
|
|
])('refuses to derive an image key from %s (%s)', (url) => {
|
|
expect(extractImageKeyFromProxyUrl(url)).toBeNull();
|
|
expect(extractImageFileNameFromProxyUrl(url)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('mediaUrlToR2Key', () => {
|
|
it('derives an image key and a voice key from canonical URLs', () => {
|
|
expect(mediaUrlToR2Key(IMAGE_URL)).toBe('images/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png');
|
|
expect(mediaUrlToR2Key(AUDIO_URL)).toBe('voice/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2.webm');
|
|
});
|
|
|
|
it('returns null for a URL that is not an image or audio proxy path', () => {
|
|
expect(mediaUrlToR2Key(VIDEO_URL)).toBeNull();
|
|
expect(mediaUrlToR2Key('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBeNull();
|
|
});
|
|
|
|
// Documented, not endorsed. Unlike extractImageKeyFromProxyUrl this one
|
|
// matches on a substring with no shape check, so the key it produces is
|
|
// attacker-shaped whenever the URL is. The module has no callers today; if
|
|
// one appears it must use the extract* helpers instead. See the report.
|
|
it('accepts a substring match that the anchored extractor rejects', () => {
|
|
const hostile = 'https://evil.test/api/upload/image/../../videos/live.mp4';
|
|
|
|
expect(extractImageKeyFromProxyUrl(hostile)).toBeNull();
|
|
expect(mediaUrlToR2Key(hostile)).toBe('images/../../videos/live.mp4');
|
|
});
|
|
});
|
|
|
|
describe('SAFE_BUNNY_VIDEO_ID', () => {
|
|
it.each([
|
|
['abcd1234', true],
|
|
['a-b_c-d1', true],
|
|
['abcd123', false],
|
|
['abcd 1234', false],
|
|
['abcd/1234', false],
|
|
['../secret', false],
|
|
['a'.repeat(129), false],
|
|
['a'.repeat(128), true],
|
|
])('matches %s: %s', (value, expected) => {
|
|
expect(SAFE_BUNNY_VIDEO_ID.test(value)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe('canDeleteAssetForViewer', () => {
|
|
const managed = {
|
|
canManageAssets: true,
|
|
viewerUserId: null,
|
|
viewerGuestIdentityId: null,
|
|
};
|
|
const signedIn = {
|
|
canManageAssets: false,
|
|
viewerUserId: 'user-1',
|
|
viewerGuestIdentityId: null,
|
|
};
|
|
const guest = {
|
|
canManageAssets: false,
|
|
viewerUserId: null,
|
|
viewerGuestIdentityId: 'guest-1',
|
|
};
|
|
|
|
it('lets a manager delete an asset they did not upload', () => {
|
|
expect(
|
|
canDeleteAssetForViewer(
|
|
{ uploadedByUserId: 'someone-else', uploadedByGuestIdentityId: null },
|
|
managed
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it('lets a signed-in uploader delete their own asset', () => {
|
|
expect(
|
|
canDeleteAssetForViewer(
|
|
{ uploadedByUserId: 'user-1', uploadedByGuestIdentityId: null },
|
|
signedIn
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it('refuses a signed-in non-manager somebody else asset', () => {
|
|
expect(
|
|
canDeleteAssetForViewer(
|
|
{ uploadedByUserId: 'user-2', uploadedByGuestIdentityId: null },
|
|
signedIn
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
it('lets a guest delete the asset their own guest identity uploaded', () => {
|
|
expect(
|
|
canDeleteAssetForViewer(
|
|
{ uploadedByUserId: null, uploadedByGuestIdentityId: 'guest-1' },
|
|
guest
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it('refuses a guest another guest asset', () => {
|
|
expect(
|
|
canDeleteAssetForViewer(
|
|
{ uploadedByUserId: null, uploadedByGuestIdentityId: 'guest-2' },
|
|
guest
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
// The `!viewer.viewerUserId` guard: a signed-in caller is judged on their user
|
|
// id alone, so a stale guest cookie carried alongside a session cannot widen
|
|
// what they may delete.
|
|
it('ignores a matching guest identity when the viewer is signed in', () => {
|
|
expect(
|
|
canDeleteAssetForViewer(
|
|
{ uploadedByUserId: null, uploadedByGuestIdentityId: 'guest-1' },
|
|
{ canManageAssets: false, viewerUserId: 'user-1', viewerGuestIdentityId: 'guest-1' }
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
// Two nulls are not a match. Without the truthiness checks an anonymous
|
|
// viewer would be able to delete every anonymously uploaded asset.
|
|
it('refuses when both sides have no identity at all', () => {
|
|
expect(
|
|
canDeleteAssetForViewer(
|
|
{ uploadedByUserId: null, uploadedByGuestIdentityId: null },
|
|
{ canManageAssets: false, viewerUserId: null, viewerGuestIdentityId: null }
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
it('refuses when the asset has no uploader and the viewer is an identified guest', () => {
|
|
expect(
|
|
canDeleteAssetForViewer({ uploadedByUserId: null, uploadedByGuestIdentityId: null }, guest)
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getVideoAssetAccessContext', () => {
|
|
function shareCookies(videoId: string, token: string, passwordVerified = false) {
|
|
return {
|
|
[getShareSessionCookieName(videoId)]: createShareSessionValue(
|
|
token,
|
|
videoId,
|
|
passwordVerified
|
|
),
|
|
};
|
|
}
|
|
|
|
it('returns null for a video that does not exist', async () => {
|
|
signedOut();
|
|
|
|
expect(
|
|
await getVideoAssetAccessContext(apiRequest('/api/videos/nope/assets'), 'nope')
|
|
).toBeNull();
|
|
});
|
|
|
|
it('denies everything to an anonymous caller on a private project', async () => {
|
|
const scenario = await seedProject({ visibility: 'PRIVATE' });
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
signedOut();
|
|
|
|
const context = await getVideoAssetAccessContext(
|
|
apiRequest(`/api/videos/${video.id}/assets`),
|
|
video.id
|
|
);
|
|
|
|
expect(context).not.toBeNull();
|
|
expect(context?.hasViewAccess).toBe(false);
|
|
expect(context?.canUploadAssets).toBe(false);
|
|
expect(context?.canDownloadAssets).toBe(false);
|
|
expect(context?.canManageAssets).toBe(false);
|
|
expect(context?.viewerUserId).toBeNull();
|
|
});
|
|
|
|
it('grants everything to the project owner and echoes the project shape back', async () => {
|
|
const scenario = await seedProject({ visibility: 'PRIVATE', allowDownloads: false });
|
|
const video = await createVideo({ projectId: scenario.project.id, title: 'Cut 3' });
|
|
signedInAs(scenario.owner);
|
|
|
|
const context = await getVideoAssetAccessContext(
|
|
apiRequest(`/api/videos/${video.id}/assets`),
|
|
video.id
|
|
);
|
|
|
|
expect(context?.hasViewAccess).toBe(true);
|
|
expect(context?.canUploadAssets).toBe(true);
|
|
// An editor may always download, whatever allowDownloads says.
|
|
expect(context?.canDownloadAssets).toBe(true);
|
|
expect(context?.canManageAssets).toBe(true);
|
|
expect(context?.viewerUserId).toBe(scenario.owner.id);
|
|
expect(context?.viewerGuestIdentityId).toBeNull();
|
|
expect(context?.video.id).toBe(video.id);
|
|
expect(context?.video.title).toBe('Cut 3');
|
|
expect(context?.video.projectId).toBe(scenario.project.id);
|
|
expect(context?.video.project.workspace.id).toBe(scenario.workspace.id);
|
|
expect(context?.video.project.workspace.ownerId).toBe(scenario.owner.id);
|
|
});
|
|
|
|
it('denies everything to a signed-in stranger', async () => {
|
|
const scenario = await seedProject({ visibility: 'PRIVATE' });
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
signedInAs(await createUser());
|
|
|
|
const context = await getVideoAssetAccessContext(
|
|
apiRequest(`/api/videos/${video.id}/assets`),
|
|
video.id
|
|
);
|
|
|
|
expect(context?.hasViewAccess).toBe(false);
|
|
expect(context?.canUploadAssets).toBe(false);
|
|
expect(context?.canDownloadAssets).toBe(false);
|
|
});
|
|
|
|
// canManageAssets is `access.canEdit`, which a COMMENTATOR does not have; the
|
|
// other three flags are separate computations and must not collapse onto it.
|
|
it('gives a project COMMENTATOR view and upload but not manage', async () => {
|
|
const scenario = await seedProject({ visibility: 'PRIVATE', allowDownloads: false });
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
const commentator = await createUser();
|
|
await addProjectMember({
|
|
projectId: scenario.project.id,
|
|
userId: commentator.id,
|
|
role: 'COMMENTATOR',
|
|
});
|
|
signedInAs(commentator);
|
|
|
|
const context = await getVideoAssetAccessContext(
|
|
apiRequest(`/api/videos/${video.id}/assets`),
|
|
video.id
|
|
);
|
|
|
|
expect(context?.hasViewAccess).toBe(true);
|
|
expect(context?.canUploadAssets).toBe(true);
|
|
expect(context?.canManageAssets).toBe(false);
|
|
// allowDownloads is off and the viewer cannot edit, so no export.
|
|
expect(context?.canDownloadAssets).toBe(false);
|
|
});
|
|
|
|
it('lets a non-editing member download once the project allows downloads', async () => {
|
|
const scenario = await seedProject({ visibility: 'PRIVATE', allowDownloads: true });
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
const commentator = await createUser();
|
|
await addProjectMember({
|
|
projectId: scenario.project.id,
|
|
userId: commentator.id,
|
|
role: 'COMMENTATOR',
|
|
});
|
|
signedInAs(commentator);
|
|
|
|
const context = await getVideoAssetAccessContext(
|
|
apiRequest(`/api/videos/${video.id}/assets`),
|
|
video.id
|
|
);
|
|
|
|
expect(context?.canDownloadAssets).toBe(true);
|
|
});
|
|
|
|
it('denies a workspace member once the workspace owner loses billing access', async () => {
|
|
const expiredOwner = await createExpiredUser();
|
|
const scenario = await seedProject({ visibility: 'PRIVATE', ownerUser: expiredOwner });
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
const member = await createUser();
|
|
await addWorkspaceMember({
|
|
workspaceId: scenario.workspace.id,
|
|
userId: member.id,
|
|
role: 'COMMENTATOR',
|
|
});
|
|
signedInAs(member);
|
|
|
|
const context = await getVideoAssetAccessContext(
|
|
apiRequest(`/api/videos/${video.id}/assets`),
|
|
video.id
|
|
);
|
|
|
|
expect(context?.hasViewAccess).toBe(false);
|
|
expect(context?.canUploadAssets).toBe(false);
|
|
});
|
|
|
|
it('grants view but not upload to an anonymous holder of a VIEW share link', async () => {
|
|
const scenario = await seedProject({ visibility: 'PRIVATE' });
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
const link = await createShareLink({
|
|
projectId: scenario.project.id,
|
|
videoId: video.id,
|
|
permission: 'VIEW',
|
|
allowGuests: true,
|
|
});
|
|
signedOut();
|
|
|
|
const context = await getVideoAssetAccessContext(
|
|
apiRequest(`/api/videos/${video.id}/assets`, {
|
|
cookies: shareCookies(video.id, link.token),
|
|
}),
|
|
video.id
|
|
);
|
|
|
|
expect(context?.hasViewAccess).toBe(true);
|
|
expect(context?.canUploadAssets).toBe(false);
|
|
expect(context?.canDownloadAssets).toBe(false);
|
|
expect(context?.canManageAssets).toBe(false);
|
|
});
|
|
|
|
it('grants upload to an anonymous holder of a COMMENT share link that allows guests', async () => {
|
|
const scenario = await seedProject({ visibility: 'PRIVATE' });
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
const link = await createShareLink({
|
|
projectId: scenario.project.id,
|
|
videoId: video.id,
|
|
permission: 'COMMENT',
|
|
allowGuests: true,
|
|
});
|
|
signedOut();
|
|
|
|
const context = await getVideoAssetAccessContext(
|
|
apiRequest(`/api/videos/${video.id}/assets`, {
|
|
cookies: shareCookies(video.id, link.token),
|
|
}),
|
|
video.id,
|
|
'COMMENT'
|
|
);
|
|
|
|
expect(context?.hasViewAccess).toBe(true);
|
|
expect(context?.canUploadAssets).toBe(true);
|
|
expect(context?.canManageAssets).toBe(false);
|
|
});
|
|
|
|
// allowGuests off means the link only works for someone with an account, and
|
|
// the upload flag is where that shows up for an anonymous caller.
|
|
it('refuses upload to an anonymous COMMENT share when guests are not allowed', async () => {
|
|
const scenario = await seedProject({ visibility: 'PRIVATE' });
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
const link = await createShareLink({
|
|
projectId: scenario.project.id,
|
|
videoId: video.id,
|
|
permission: 'COMMENT',
|
|
allowGuests: false,
|
|
});
|
|
signedOut();
|
|
|
|
const context = await getVideoAssetAccessContext(
|
|
apiRequest(`/api/videos/${video.id}/assets`, {
|
|
cookies: shareCookies(video.id, link.token),
|
|
}),
|
|
video.id,
|
|
'COMMENT'
|
|
);
|
|
|
|
expect(context?.canUploadAssets).toBe(false);
|
|
});
|
|
|
|
it('grants download through a share link that allows downloads', async () => {
|
|
const scenario = await seedProject({ visibility: 'PRIVATE', allowDownloads: false });
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
const link = await createShareLink({
|
|
projectId: scenario.project.id,
|
|
videoId: video.id,
|
|
permission: 'VIEW',
|
|
allowGuests: true,
|
|
allowDownloads: true,
|
|
});
|
|
signedOut();
|
|
|
|
const context = await getVideoAssetAccessContext(
|
|
apiRequest(`/api/videos/${video.id}/assets`, {
|
|
cookies: shareCookies(video.id, link.token),
|
|
}),
|
|
video.id
|
|
);
|
|
|
|
expect(context?.canDownloadAssets).toBe(true);
|
|
});
|
|
|
|
// The cookie is bound to a video id and HMAC-signed, so a session minted for
|
|
// one video must not carry over to another.
|
|
it('ignores a share cookie minted for a different video', async () => {
|
|
const scenario = await seedProject({ visibility: 'PRIVATE' });
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
const otherVideo = await createVideo({ projectId: scenario.project.id });
|
|
const link = await createShareLink({
|
|
projectId: scenario.project.id,
|
|
videoId: otherVideo.id,
|
|
permission: 'COMMENT',
|
|
allowGuests: true,
|
|
});
|
|
signedOut();
|
|
|
|
const context = await getVideoAssetAccessContext(
|
|
apiRequest(`/api/videos/${video.id}/assets`, {
|
|
cookies: shareCookies(otherVideo.id, link.token),
|
|
}),
|
|
video.id,
|
|
'COMMENT'
|
|
);
|
|
|
|
expect(context?.hasViewAccess).toBe(false);
|
|
expect(context?.canUploadAssets).toBe(false);
|
|
});
|
|
|
|
it('reads the guest identity for an anonymous caller and drops it for a signed-in one', async () => {
|
|
const scenario = await seedProject({ visibility: 'PUBLIC' });
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
// A signed cookie is the only shape getGuestIdentityFromRequest accepts, so
|
|
// it is minted the same way the comment routes mint it.
|
|
const { NextResponse } = await import('next/server');
|
|
const { setGuestIdentityCookie } = await import('@/lib/guest-identity');
|
|
const carrier = NextResponse.json({});
|
|
setGuestIdentityCookie(carrier, 'guest-identity-under-test');
|
|
const cookieValue = carrier.cookies.get('openframe_guest_identity')?.value ?? '';
|
|
const cookies = { openframe_guest_identity: cookieValue };
|
|
|
|
signedOut();
|
|
const anonymous = await getVideoAssetAccessContext(
|
|
apiRequest(`/api/videos/${video.id}/assets`, { cookies }),
|
|
video.id
|
|
);
|
|
expect(anonymous?.viewerGuestIdentityId).toBe('guest-identity-under-test');
|
|
expect(anonymous?.viewerUserId).toBeNull();
|
|
|
|
signedInAs(scenario.owner);
|
|
const authenticated = await getVideoAssetAccessContext(
|
|
apiRequest(`/api/videos/${video.id}/assets`, { cookies }),
|
|
video.id
|
|
);
|
|
expect(authenticated?.viewerUserId).toBe(scenario.owner.id);
|
|
expect(authenticated?.viewerGuestIdentityId).toBeNull();
|
|
});
|
|
|
|
it('gives an anonymous caller view access to a PUBLIC project but no upload or manage', async () => {
|
|
const scenario = await seedProject({ visibility: 'PUBLIC' });
|
|
const video = await createVideo({ projectId: scenario.project.id });
|
|
signedOut();
|
|
|
|
const context = await getVideoAssetAccessContext(
|
|
apiRequest(`/api/videos/${video.id}/assets`),
|
|
video.id
|
|
);
|
|
|
|
expect(context?.hasViewAccess).toBe(true);
|
|
expect(context?.canUploadAssets).toBe(false);
|
|
expect(context?.canManageAssets).toBe(false);
|
|
expect(context?.canDownloadAssets).toBe(false);
|
|
});
|
|
});
|