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.
This commit is contained in:
yusufipk
2026-07-26 18:53:54 +07:00
parent 0ceba72d5b
commit b51e690062
111 changed files with 1665 additions and 804 deletions
+7 -5
View File
@@ -442,7 +442,10 @@ describe('DELETE /api/videos/[videoId]/assets/[assetId]', () => {
// 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', () => {
it('returns 403 to a signed-in stranger', async () => {
// 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();
@@ -454,8 +457,7 @@ describe('GET /api/videos/[videoId]/assets/[assetId]/download', () => {
{ videoId: fixture.video.id, assetId: fixture.asset.id }
);
expect(response.status).toBe(403);
expect(await readError(response)).toContain('Access denied');
expect(response.status).toBe(404);
});
it('returns 403 to a project COMMENTATOR when downloads are disabled', async () => {
@@ -529,7 +531,7 @@ describe('GET /api/videos/[videoId]/assets/[assetId]/download', () => {
expect(response.status).toBe(404);
});
it('returns 403 for a foreign asset reached through its own foreign video id', async () => {
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);
@@ -540,7 +542,7 @@ describe('GET /api/videos/[videoId]/assets/[assetId]/download', () => {
{ videoId: theirs.video.id, assetId: theirs.asset.id }
);
expect(response.status).toBe(403);
expect(response.status).toBe(404);
});
});
+15 -1
View File
@@ -765,7 +765,21 @@ const NON_AUTHORIZATION_REFUSALS = new Map<string, string>();
* fails and tells you to delete it, so nothing can rot into a permanent
* exemption.
*/
const NOT_FOUND_IS_THE_GUARD = new Map<string, string>();
const NOT_FOUND_IS_THE_GUARD = new Map<string, string>([
// Both download routes take a bare resource id with no project in the path, so a 403
// for an id belonging to another tenant would confirm that the id exists. They answer
// 404 to any caller with no relationship to the project, which is what
// versions/[versionId]/comments/export has always done for the identical shape.
// Somebody who does belong, an owner whose billing lapsed for instance, still gets 403.
[
'GET versions/[versionId]/download/route.ts',
'hides whether the version id exists from a caller with no relationship to it',
],
[
'GET videos/[videoId]/assets/[assetId]/download/route.ts',
'hides whether the video id exists from a caller with no relationship to it',
],
]);
function discoverRouteModules(): string[] {
const apiDir = path.join(REPO_ROOT, 'app', 'api');
+8 -5
View File
@@ -332,7 +332,10 @@ describe('GET /api/projects/[projectId]/download', () => {
// DownloadEgressEvent row was written, because that row is the billing record: a
// refusal that still bills the workspace owner would be its own bug.
describe('GET /api/versions/[versionId]/download', () => {
it('returns 403 to a signed-in stranger and records no egress', async () => {
// 404 rather than 403 for a caller with no relationship to the project: a 403 would
// confirm 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 and records no egress', async () => {
const fixture = await seedDownloadable({ allowDownloads: true });
await seedProject();
const stranger = await createUser();
@@ -344,7 +347,7 @@ describe('GET /api/versions/[versionId]/download', () => {
{ versionId: fixture.version.id }
);
expect(response.status).toBe(403);
expect(response.status).toBe(404);
expect(await db.downloadEgressEvent.count()).toBe(0);
});
@@ -429,9 +432,9 @@ describe('GET /api/versions/[versionId]/download', () => {
// Straight identifier substitution. There is no projectId in this URL to
// cross-check against, so the version id alone decides which project gets
// authorized. A caller who owns a perfectly good project of their own gets 403
// authorized. A caller who owns a perfectly good project of their own gets 404
// for somebody else's version, and never learns whether it exists.
it('returns 403 for a version id belonging to another workspace', async () => {
it('returns 404 for a version id belonging to another workspace', async () => {
const mine = await seedDownloadable({ allowDownloads: true });
const theirs = await seedDownloadable({ allowDownloads: true });
signedInAs(mine.owner);
@@ -442,7 +445,7 @@ describe('GET /api/versions/[versionId]/download', () => {
{ versionId: theirs.version.id }
);
expect(response.status).toBe(403);
expect(response.status).toBe(404);
expect(await db.downloadEgressEvent.count()).toBe(0);
});
+89 -12
View File
@@ -450,11 +450,10 @@ describe('acceptInvitationTokenForUser', () => {
expect(membership.role).toBe('ADMIN');
});
// The invited role wins over the role the member already holds, so accepting
// a COMMENTATOR invitation demotes a sitting workspace ADMIN. Pinned because
// it is a privilege change, and a surprising one: the accept link looks like
// it can only ever add access.
it('applies a COMMENTATOR invitation over an existing ADMIN membership', async () => {
// An invitation can only ever add access. Applying the invited role over an existing
// membership made it a privilege-change primitive: re-invite a sitting ADMIN at a lower
// role, get them to click the link once, and they are demoted.
it('leaves an existing ADMIN membership alone for a COMMENTATOR invitation', async () => {
const scenario = await seedProject();
const member = await createUser({ email: '[email protected]' });
await addWorkspaceMember({
@@ -480,7 +479,66 @@ describe('acceptInvitationTokenForUser', () => {
const membership = await db.workspaceMember.findUniqueOrThrow({
where: { workspaceId_userId: { workspaceId: scenario.workspace.id, userId: member.id } },
});
expect(membership.role).toBe('COMMENTATOR');
expect(membership.role).toBe('ADMIN');
});
it('leaves an existing project ADMIN alone for a COMMENTATOR invitation', async () => {
const scenario = await seedProject();
const member = await createUser({ email: '[email protected]' });
await addProjectMember({
projectId: scenario.project.id,
userId: member.id,
role: 'ADMIN',
});
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'PROJECT',
projectId: scenario.project.id,
role: 'COMMENTATOR',
});
const result = await acceptInvitationTokenForUser({
token: invitation.token,
userId: member.id,
email: member.email!,
});
expect(result).toBe('accepted');
const membership = await db.projectMember.findUniqueOrThrow({
where: { projectId_userId: { projectId: scenario.project.id, userId: member.id } },
});
expect(membership.role).toBe('ADMIN');
});
// The other direction still has to work: an invitation is allowed to promote.
it('promotes an existing COMMENTATOR to ADMIN for an ADMIN invitation', async () => {
const scenario = await seedProject();
const member = await createUser({ email: '[email protected]' });
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: member.id,
role: 'COMMENTATOR',
});
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'WORKSPACE',
workspaceId: scenario.workspace.id,
role: 'ADMIN',
});
const result = await acceptInvitationTokenForUser({
token: invitation.token,
userId: member.id,
email: member.email!,
});
expect(result).toBe('accepted');
const membership = await db.workspaceMember.findUniqueOrThrow({
where: { workspaceId_userId: { workspaceId: scenario.workspace.id, userId: member.id } },
});
expect(membership.role).toBe('ADMIN');
});
// The owner already outranks any membership row. Writing one would put them
@@ -532,11 +590,10 @@ describe('acceptInvitationTokenForUser', () => {
);
});
// Pins today's behaviour for a malformed row (scope WORKSPACE with no
// workspaceId): the caller is told "accepted" while nothing is granted and
// the invitation stays PENDING, so the accept page shows a success screen.
// See the report accompanying this suite.
it('reports accepted for a scoped invitation that points at nothing', async () => {
// A malformed row (scope WORKSPACE with no workspaceId) grants nothing. Reporting
// "accepted" for it showed the user a success screen for a no-op they had no way to
// detect, while the invitation stayed PENDING for good.
it('refuses a scoped invitation that points at nothing', async () => {
const scenario = await seedProject();
const invitee = await createUser({ email: '[email protected]' });
const invitation = await createInvitation({
@@ -552,12 +609,32 @@ describe('acceptInvitationTokenForUser', () => {
email: invitee.email!,
});
expect(result).toBe('accepted');
expect(result).toBe('not_found');
expect(await db.workspaceMember.count()).toBe(0);
expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe(
'PENDING'
);
});
it('refuses a project invitation that points at nothing', async () => {
const scenario = await seedProject();
const invitee = await createUser({ email: '[email protected]' });
const invitation = await createInvitation({
invitedById: scenario.owner.id,
email: '[email protected]',
scope: 'PROJECT',
projectId: null,
});
const result = await acceptInvitationTokenForUser({
token: invitation.token,
userId: invitee.id,
email: invitee.email!,
});
expect(result).toBe('not_found');
expect(await db.projectMember.count()).toBe(0);
});
});
describe('acceptPendingInvitationsForUser', () => {
+16 -9
View File
@@ -437,16 +437,18 @@ describe('getCachedBunnyStorageStats', () => {
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: -1, byVideoId: {} });
});
// The flag defaults to on, so a self-hosted deployment that never configured
// Bunny lands here: credentials missing while the feature is nominally
// enabled.
it('degrades to -1 when the Bunny credentials are not configured', async () => {
// The flag defaults to on, so a self-hosted deployment that never configured Bunny
// lands here: credentials missing while the feature is nominally enabled. That used to
// key on the flag alone, throw "Missing Bunny Stream credentials." out of
// getBunnyConfig() and report -1, which reads as "we could not measure" rather than
// "there is nothing to measure".
it('reports a genuine zero when the Bunny credentials are not configured', async () => {
vi.stubEnv('BUNNY_STREAM_API_KEY', undefined);
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', undefined);
vi.stubEnv('NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID', undefined);
const calls = stubBunnyPages([]);
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: -1, byVideoId: {} });
expect(await getCachedBunnyStorageStats()).toEqual({ totalBytes: 0, byVideoId: {} });
expect(calls.urls).toEqual([]);
});
@@ -875,16 +877,18 @@ describe('getCachedStripeStats', () => {
pastDueUsers: 1,
canceledUsers: 1,
freeUsers: 3,
otherStatusUsers: 0,
mrrCents: 3800,
currency: 'eur',
});
expect(stripe.retrievedPriceIds).toEqual(['price_admin_stats_test']);
});
// UNPAID, INCOMPLETE and INCOMPLETE_EXPIRED are real values of the enum that
// the report has no bucket for. They must not be silently folded into one of
// the five that are reported.
it('leaves statuses it does not report out of every bucket', async () => {
// UNPAID, INCOMPLETE and INCOMPLETE_EXPIRED are real values of the enum that used to
// belong to none of the reported buckets, so those users were counted nowhere and the
// five totals silently did not add up to the user table. They must not be folded into
// one of the five either.
it('counts the statuses the five named buckets do not cover', async () => {
await createUser({ subscriptionStatus: 'UNPAID' });
await createUser({ subscriptionStatus: 'INCOMPLETE' });
await createUser({ subscriptionStatus: 'INCOMPLETE_EXPIRED' });
@@ -898,6 +902,7 @@ describe('getCachedStripeStats', () => {
pastDueUsers: 0,
canceledUsers: 0,
freeUsers: 0,
otherStatusUsers: 3,
mrrCents: 0,
currency: 'usd',
});
@@ -912,6 +917,7 @@ describe('getCachedStripeStats', () => {
pastDueUsers: 0,
canceledUsers: 0,
freeUsers: 0,
otherStatusUsers: 0,
mrrCents: 0,
currency: 'usd',
});
@@ -932,6 +938,7 @@ describe('getCachedStripeStats', () => {
pastDueUsers: 0,
canceledUsers: 0,
freeUsers: 0,
otherStatusUsers: 0,
mrrCents: 0,
currency: 'usd',
});
+13 -80
View File
@@ -41,11 +41,6 @@ import {
createWorkspace,
} from '../factories';
type Intent = 'view' | 'manage' | 'delete';
/** `undefined` stands for a caller that passes no options at all. */
const INTENTS: ReadonlyArray<Intent | undefined> = [undefined, 'view', 'manage', 'delete'];
type Actor =
| 'an anonymous caller'
| 'an outsider'
@@ -252,42 +247,6 @@ const SCENARIOS: readonly Scenario[] = [
];
// ---------------------------------------------------------------------------
// The known divergence
// ---------------------------------------------------------------------------
/**
* The one place the two functions disagree, spelled out rather than filtered
* out.
*
* `shouldLoadWorkspaceRole` in lib/auth.ts skips the workspace queries for a
* project owner on `intent: 'view'`, on the reasoning that an owner passes every
* check on their own. That is true of the three permission booleans, and it is
* why this is harmless today, but it is not true of the two identity flags:
* checkProjectAccess() reports `isWorkspaceMember: false, isWorkspaceAdmin:
* false` for an owner who is in fact the workspace owner, where
* computeProjectAccess() on the same rows reports true and true.
*
* Harmless today rests on one fact and not on the design: the only consumer of
* `isWorkspaceMember` from checkProjectAccess() is
* app/api/versions/[versionId]/approvals/route.ts:37, and its
* `isOwner || isProjectMember || isWorkspaceMember` is already satisfied by
* `isOwner` for exactly the actor that diverges. Nothing consumes
* `isWorkspaceAdmin` from checkProjectAccess() at all; `canEdit` folds it in,
* and `isOwner` covers that too. The next reader of either flag inherits the
* bug, which is why this is asserted instead of hidden behind a comparison of
* `hasAccess` alone.
*
* If this stops matching, the divergence was closed: delete this function and
* the test at the bottom of the file rather than widening either of them.
*/
function knownDivergence(actor: Actor, intent: Intent | undefined): Partial<ExpectedAccess> {
const resolvesWorkspaceRole = intent === 'manage' || intent === 'delete';
if (actor === 'a project owner who also owns the workspace' && !resolvesWorkspaceRole) {
return { isWorkspaceMember: false, isWorkspaceAdmin: false };
}
return {};
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
@@ -428,7 +387,7 @@ for (const scenario of SCENARIOS) {
});
for (const actor of ACTORS) {
it(`resolves ${actor} exactly as computeProjectAccess does, at every intent`, async () => {
it(`resolves ${actor} exactly as computeProjectAccess does`, async () => {
const userId = seeded.userIdFor(actor);
const projectId = projectIdFor(actor, seeded);
const expected = scenario.expected[actor];
@@ -437,20 +396,10 @@ for (const scenario of SCENARIOS) {
// The pure half first: given the rows, this is the answer.
expect(computeProjectAccess(enriched, userId), 'computeProjectAccess').toEqual(expected);
// And the querying half, which has to arrive at the same place from the
// same rows, whatever the caller says it intends to do.
for (const intent of INTENTS) {
const checked = await checkProjectAccess(
enriched,
userId,
intent === undefined ? undefined : { intent }
);
expect(checked, `checkProjectAccess with intent ${intent ?? '(default)'}`).toEqual({
...expected,
...knownDivergence(actor, intent),
});
}
// And the querying half, which has to arrive at the same place from the same rows.
// It used to take an `intent` that skipped the workspace queries for an owner at
// `view`, which is what made these two disagree; there is one code path now.
expect(await checkProjectAccess(enriched, userId), 'checkProjectAccess').toEqual(expected);
});
}
});
@@ -460,8 +409,13 @@ for (const scenario of SCENARIOS) {
// The divergence, on its own
// ---------------------------------------------------------------------------
describe('checkProjectAccess and computeProjectAccess disagree in one place', () => {
it('hides the workspace role from a project owner who also owns the workspace, on intent view', async () => {
describe('checkProjectAccess and computeProjectAccess agree about the workspace role', () => {
// This was the one cell of the matrix that diverged, and it is the shape every real
// signup produces: app/api/projects/route.ts gives a new project the workspace owner's
// id. checkProjectAccess() used to skip both workspace queries for an owner at `view`
// intent, so the two identity flags read as if this user were a stranger to the
// workspace they own.
it('reports a project owner who also owns the workspace as a workspace admin', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
const project = await createProject({
@@ -471,32 +425,11 @@ describe('checkProjectAccess and computeProjectAccess disagree in one place', ()
});
const enriched = await fetchEnriched(project.id, owner.id);
const computed = computeProjectAccess(enriched, owner.id);
const viewed = await checkProjectAccess(enriched, owner.id, { intent: 'view' });
const managed = await checkProjectAccess(enriched, owner.id, { intent: 'manage' });
// What the rows say: this user owns the workspace the project lives in.
expect(computed.isWorkspaceMember).toBe(true);
expect(computed.isWorkspaceAdmin).toBe(true);
// What checkProjectAccess() says on the intent that pages and GET routes
// use. `shouldLoadWorkspaceRole` skipped the workspace queries, so the role
// was never resolved and the two flags read as if this user were a stranger
// to the workspace.
expect(viewed.isWorkspaceMember).toBe(false);
expect(viewed.isWorkspaceAdmin).toBe(false);
// Ask the same question with a mutating intent and the same user, on the
// same rows, is a workspace owner again.
expect(managed.isWorkspaceMember).toBe(true);
expect(managed.isWorkspaceAdmin).toBe(true);
// Why nobody has noticed: every permission the flags feed is already
// granted by isOwner, so the divergence stops at the two identity flags.
expect(viewed.hasAccess).toBe(true);
expect(viewed.canEdit).toBe(true);
expect(viewed.canDelete).toBe(true);
expect({ ...viewed, isWorkspaceMember: true, isWorkspaceAdmin: true }).toEqual(computed);
expect(await checkProjectAccess(enriched, owner.id)).toEqual(computed);
});
});
+9 -8
View File
@@ -180,19 +180,20 @@ describe('cancelR2UploadSession', () => {
).toBe('FINALIZED');
});
// The `expiresAt: { gt: now }` clause means an expired session cannot be
// cancelled at all: the row stays INITIATED and consumedAt stays null. That
// is the current contract, and it is why the sweeper rather than the route
// has to be the thing that reclaims those reservations. See the report.
it('matches nothing once the session has expired, leaving it INITIATED', async () => {
// An `expiresAt: { gt: now }` clause used to make this match zero rows once a session
// lapsed, so it stayed INITIATED with a null consumedAt for good. The r2-init DELETE
// route releases the quota reservation only when the update reports a row, so every
// abandoned upload held its reserved bytes against the user's quota permanently.
// Cancelling something already expired is the case that most needs to work.
it('cancels a session that has already expired', async () => {
const { session } = await newSession({ expiresAt: new Date(Date.now() - 60_000) });
const result = await cancelR2UploadSession(session.id);
expect(result.count).toBe(0);
expect(result.count).toBe(1);
const row = await db.videoUploadSession.findUniqueOrThrow({ where: { id: session.id } });
expect(row.status).toBe('INITIATED');
expect(row.consumedAt).toBeNull();
expect(row.status).toBe('CANCELLED');
expect(row.consumedAt).not.toBeNull();
});
it('does nothing for an id that matches no row', async () => {
+16 -18
View File
@@ -18,7 +18,6 @@ import {
extractVideoFileNameFromProxyUrl,
extractVideoKeyFromProxyUrl,
getVideoAssetAccessContext,
mediaUrlToR2Key,
sanitizeAssetDisplayName,
SAFE_BUNNY_VIDEO_ID,
} from '@/lib/video-assets';
@@ -132,26 +131,25 @@ describe('proxy URL extraction', () => {
});
});
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', () => {
// mediaUrlToR2Key used to live here. It matched the proxy prefix as a substring with no
// shape check, so `https://evil.test/api/upload/image/../../videos/live.mp4` produced the
// key `images/../../videos/live.mp4`. It had no callers, so it was deleted rather than
// reimplemented; the anchored extract* helpers below are what a caller should use.
describe('the anchored extractors refuse a substring match', () => {
it('rejects a hostile url that only contains the proxy prefix', () => {
const hostile = 'https://evil.test/api/upload/image/../../videos/live.mp4';
expect(extractImageKeyFromProxyUrl(hostile)).toBeNull();
expect(mediaUrlToR2Key(hostile)).toBe('images/../../videos/live.mp4');
expect(extractImageFileNameFromProxyUrl(hostile)).toBeNull();
});
it('still derives keys from canonical urls', () => {
expect(extractImageKeyFromProxyUrl(IMAGE_URL)).toBe(
'images/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png'
);
expect(extractAudioKeyFromProxyUrl(AUDIO_URL)).toBe(
'voice/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2.webm'
);
});
});
+40 -21
View File
@@ -14,7 +14,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { deleteProjectVideosWithCleanup } from '@/lib/video-delete';
import { deleteProjectVideosWithCleanup, VideoStorageCleanupError } from '@/lib/video-delete';
import {
createComment,
createProject,
@@ -372,12 +372,10 @@ describe('deleteProjectVideosWithCleanup and Bunny', () => {
});
describe('deleteProjectVideosWithCleanup when storage fails', () => {
// The rows go first and the objects go second, with no transaction spanning
// the two. A refused DELETE therefore leaves the object behind with nothing
// in the database still pointing at it: the caller cannot retry, because the
// video id it would retry with no longer resolves. The result object is the
// only trace, which is why the warnings are asserted here.
it('still removes the rows and surfaces the orphaned key as a warning', async () => {
// Storage runs first and the rows go second. Deleting the rows first left the object in
// the bucket with nothing pointing at it and no way to retry, because the video id no
// longer resolved. Keeping the rows makes the delete repeatable.
it('keeps the rows and reports the failure when a key cannot be deleted', async () => {
const scenario = await seedProject();
const target = await seedDeletableVideo({
projectId: scenario.project.id,
@@ -387,22 +385,40 @@ describe('deleteProjectVideosWithCleanup when storage fails', () => {
});
r2.rejectKeys.add(TARGET_VIDEO_KEY);
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id]);
await expect(
deleteProjectVideosWithCleanup(scenario.project.id, [target.video.id])
).rejects.toBeInstanceOf(VideoStorageCleanupError);
expect(result.deletedCount).toBe(1);
// The row is gone even though its object is not.
expect(await db.video.count()).toBe(0);
expect(result.cleanupInput.r2).toEqual({
attempted: 2,
failed: 1,
failedKeys: [TARGET_VIDEO_KEY],
});
expect(result.cleanupWarnings).toEqual({ r2: { attempted: 2, failed: 1 } });
// The row is still there, so the caller can try again.
expect(await db.video.count()).toBe(1);
// The rest of the sweep still ran.
expect(r2.deletedKeys).toEqual([TARGET_COMMENT_IMAGE_KEY]);
});
it('reports a Bunny failure without failing the delete', async () => {
it('carries the failed keys on the error so the route can log them', async () => {
const scenario = await seedProject();
const target = await seedDeletableVideo({
projectId: scenario.project.id,
ownerId: scenario.owner.id,
videoUrl: TARGET_VIDEO_URL,
commentImageUrl: TARGET_COMMENT_IMAGE,
});
r2.rejectKeys.add(TARGET_VIDEO_KEY);
const error = await deleteProjectVideosWithCleanup(scenario.project.id, [
target.video.id,
]).catch((err: unknown) => err as VideoStorageCleanupError);
expect(error.cleanupInput.r2).toEqual({
attempted: 2,
failed: 1,
failedKeys: [TARGET_VIDEO_KEY],
});
});
// A Bunny video that survives the delete is billed and invisible in the app, so it gets
// the same treatment as an orphaned R2 object.
it('keeps the rows when Bunny refuses the delete', async () => {
vi.stubEnv('BUNNY_STREAM_API_KEY', 'test-bunny-key');
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '9999');
vi.stubGlobal(
@@ -417,10 +433,13 @@ describe('deleteProjectVideosWithCleanup when storage fails', () => {
providerVideoId: 'bunny-version-id-2',
});
const result = await deleteProjectVideosWithCleanup(scenario.project.id, [video.id]);
const error = await deleteProjectVideosWithCleanup(scenario.project.id, [video.id]).catch(
(err: unknown) => err as VideoStorageCleanupError
);
expect(await db.video.count()).toBe(0);
expect(result.cleanupWarnings).toEqual({ bunny: { attempted: 1, failed: 1 } });
expect(error).toBeInstanceOf(VideoStorageCleanupError);
expect(error.cleanupInput.bunny).toMatchObject({ attempted: 1, failed: 1 });
expect(await db.video.count()).toBe(1);
});
it('reports no warnings when both providers succeed', async () => {
+5 -6
View File
@@ -151,11 +151,10 @@ describe('GET /api/projects', () => {
expect(projects.projects.map((entry) => entry.id)).toEqual([scenario.project.id]);
});
// Documents current behaviour, which looks like a bug. See the note in the
// report: the workspace-membership branch of the OR is dropped as soon as
// ?workspaceId is supplied, so filtering by workspace hides exactly the
// projects the unfiltered call returns.
it('stops listing workspace-member projects once ?workspaceId is supplied', async () => {
// 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 });
@@ -166,7 +165,7 @@ describe('GET /api/projects', () => {
);
expect(unfiltered.projects.map((entry) => entry.id)).toEqual([scenario.project.id]);
expect(filtered.projects).toEqual([]);
expect(filtered.projects.map((entry) => entry.id)).toEqual([scenario.project.id]);
});
it('scopes ?workspaceId to that workspace for an owner of several', async () => {
+24 -20
View File
@@ -165,16 +165,16 @@ describe('checkRateLimit', () => {
expect(result.remaining).toBe(RATE_LIMIT_CONFIGS.api.maxRequests - 1);
});
// Defence in depth against oversized values reaching the query. The call is
// allowed but nothing is recorded, so an attacker cannot use a huge key to
// bloat the table either.
it('allows and records nothing for an over-long key or action', async () => {
// An oversized value is hashed to fit its column rather than skipped, so it is written
// and counted like any other. A huge key cannot bloat the table either: what lands in
// the column is a fixed-width digest.
it('records an over-long key and an over-long action', async () => {
const longKey = await checkRateLimit('x'.repeat(257), 'login', CONFIG);
const longAction = await checkRateLimit('1.2.3.4', 'y'.repeat(65), CONFIG);
expect(longKey.allowed).toBe(true);
expect(longAction.allowed).toBe(true);
expect(await countRows('rate_limits')).toBe(0);
expect(await countRows('rate_limits')).toBe(2);
});
it('records a key of exactly 255 characters, the column width', async () => {
@@ -184,27 +184,31 @@ describe('checkRateLimit', () => {
expect(await countRows('rate_limits')).toBe(1);
});
// Documents an off-by-one, reported rather than fixed. The guard in
// lib/rate-limit.ts rejects `key.length > 256`, but rate_limits.key is
// VARCHAR(255), so a 256-character key clears the guard and then fails the
// INSERT with P2010. The catch treats any database error as "allow", so such a
// key is never counted and the limit silently stops applying to it.
//
// Not reachable from the product today: every call site builds a key from an
// IP, a user id or a 24-character hash. The failure mode is fail-open, so a
// future longer key would disable a limit rather than break a page.
it('fails open for a 256-character key instead of counting it', async () => {
// Kept to four attempts, one past the limit, because each one logs the
// swallowed Postgres error and the point is made without ten copies of it.
// This is the case that used to fail open twice over: the guard allowed a 256-character
// key through, the INSERT then failed with SQLSTATE 22001 against a VARCHAR(255) column,
// and the catch answered "allowed" for every attempt. The key is now hashed before it
// reaches the query, so the limit applies to it like any other.
it('counts a 256-character key and blocks it past the cap', async () => {
const key = 'x'.repeat(256);
for (let attempt = 0; attempt < 4; attempt += 1) {
for (let attempt = 0; attempt < CONFIG.maxRequests; attempt += 1) {
const result = await checkRateLimit(key, 'login', CONFIG);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(CONFIG.maxRequests);
}
expect(await countRows('rate_limits')).toBe(0);
const blocked = await checkRateLimit(key, 'login', CONFIG);
expect(blocked.allowed).toBe(false);
expect(blocked.remaining).toBe(0);
expect(await countRows('rate_limits')).toBe(1);
expect((await db.rateLimit.findFirstOrThrow()).count).toBe(CONFIG.maxRequests + 1);
});
it('keeps two different over-long keys in separate buckets', async () => {
await checkRateLimit(`a${'x'.repeat(300)}`, 'login', CONFIG);
await checkRateLimit(`b${'x'.repeat(300)}`, 'login', CONFIG);
expect(await countRows('rate_limits')).toBe(2);
});
it('counts concurrent calls exactly once each', async () => {
+37 -12
View File
@@ -297,14 +297,14 @@ describe('GET /api/search reaches everything the caller is entitled to', () => {
// ---------------------------------------------------------------------------
// 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.
// Search carries the same billing condition every other read path does. It used to carry
// none: 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, while search went on returning names, descriptions and video
// titles for the same tenant.
describe('GET /api/search and lapsed billing', () => {
it('keeps returning a project whose workspace owner has lost billing access', async () => {
it('hides 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` });
@@ -312,11 +312,22 @@ describe('GET /api/search and lapsed billing', () => {
const results = await searchFor(term);
expect(results.projects.map((entry) => entry.name)).toEqual([`${term} lapsed project`]);
expect(results.projects).toEqual([]);
});
// 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.
// The positive control: the same shape with billing intact still comes back, so the
// assertion above is about billing and not about the fixture failing to seed.
it('still returns a project whose workspace owner is paying', async () => {
const term = uniqueTerm();
const scenario = await seedProject({ projectName: `${term} live project` });
signedInAs(scenario.owner);
const results = await searchFor(term);
expect(results.projects.map((entry) => entry.name)).toEqual([`${term} live project`]);
});
// The same caller, the same row, through the list endpoint instead: the two agree now.
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' });
@@ -329,7 +340,7 @@ describe('GET /api/search and lapsed billing', () => {
expect(projects).toEqual([]);
});
it('keeps returning a video title from a lapsed workspace to a collaborator', async () => {
it('hides a video title from a lapsed workspace, even from a collaborator', async () => {
const term = uniqueTerm();
const expiredOwner = await createExpiredUser();
const { project } = await seedProject({ ownerUser: expiredOwner });
@@ -340,6 +351,20 @@ describe('GET /api/search and lapsed billing', () => {
const results = await searchFor(term);
expect(results.videos.map((entry) => entry.title)).toEqual([`${term} lapsed cut`]);
expect(results.videos).toEqual([]);
});
it('still returns a video title to a collaborator while the owner is paying', async () => {
const term = uniqueTerm();
const { project } = await seedProject();
await createVideo({ projectId: project.id, title: `${term} live 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} live cut`]);
});
});
+9 -9
View File
@@ -243,19 +243,19 @@ describe('CommentRichText asset mentions', () => {
expect(screen.getByRole('button', { name: '@https://evil.test/x' })).toBeInTheDocument();
});
// KNOWN BUG, pinned rather than fixed. `renderUrls` keys its fragments by the
// index within its own slice, and CommentRichText calls it once per gap
// between mentions, so the same key ("txt-0") is emitted for several
// siblings. React logs "Encountered two children with the same key" and warns
// that children may be duplicated or omitted. The output happens to be
// correct today; the text assertion locks that in, and the warning assertion
// is the thing to delete once the keys are made unique.
it('produces duplicate React keys when text surrounds a mention', () => {
// `renderUrls` used to key its fragments by the index within its own slice, and
// CommentRichText calls it once per gap between mentions, so the same key ("txt-0") was
// emitted for several siblings and React warned that children may be duplicated or
// omitted. The keys carry the slice offset now.
it('emits no duplicate React keys when text surrounds a mention', () => {
const { container } = render(
<CommentRichText text="Before @[One](asset:aaa111) middle @[Two](asset:bbb222) after" />
);
expect(container).toHaveTextContent('Before @One middle @Two after');
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('same key'), 'txt-0');
expect(consoleError).not.toHaveBeenCalledWith(
expect.stringContaining('same key'),
expect.anything()
);
});
});
@@ -733,15 +733,12 @@ describe('useCommentActions editing', () => {
expect(findComment(harness, 'c1')?.content).toBe('Existing note');
});
// KNOWN BUG, pinned rather than fixed. `editTagId` is typed `string | null`
// and initialised to `null`, so the `editTagId !== undefined` guard in the
// hook can never be false: every edit PATCH carries a `tagId`, and every
// successful edit overwrites the comment's tag with whatever `editTagId`
// happens to hold. The comment editor in comments-pane.tsx seeds it from the
// comment, but the REPLY editor (comments-pane.tsx, "Edit" on a reply) sets
// only editingCommentId and editText, so editing a reply's text silently
// sends tagId: null.
it('always sends a tagId, and clears the tag, even when the caller never set one', async () => {
// `editTagId` was initialised to `null`, so the `editTagId !== undefined` guard could
// never be false: every edit PATCH carried a `tagId` and every success overwrote the
// comment's tag. The comment editor seeds the value from the comment, but the reply
// editor sets only editingCommentId and editText, so editing a reply's text silently
// cleared its tag or applied a stale one. `undefined` now means "not managed here".
it('sends no tagId when the caller never set one, and leaves the tag alone', async () => {
const harness = renderActions();
act(() => harness.result.current.actions.setEditText('Reworded note'));
@@ -749,6 +746,23 @@ describe('useCommentActions editing', () => {
await harness.result.current.actions.handleEditComment('c1');
});
expect(bodyOf(callsTo('/api/comments/c1', 'PATCH')[0])).toEqual({
content: 'Reworded note',
});
expect(findComment(harness, 'c1')?.tag).toEqual(TAGS[0]);
});
it('sends tagId: null when the editor explicitly clears the tag', async () => {
const harness = renderActions();
act(() => {
harness.result.current.actions.setEditText('Reworded note');
harness.result.current.actions.setEditTagId(null);
});
await act(async () => {
await harness.result.current.actions.handleEditComment('c1');
});
expect(bodyOf(callsTo('/api/comments/c1', 'PATCH')[0])).toEqual({
content: 'Reworded note',
tagId: null,
@@ -619,18 +619,19 @@ describe('useDownloadActions repeated clicks', () => {
});
});
// KNOWN FRAGILITY, pinned rather than fixed. The in-flight guard reads
// `isDownloadingVideo` out of the closure the callback was created in, so two
// calls made from the SAME render (a double click landing before React
// commits the state update) both get through and the file is fetched twice.
it('lets two calls from the same render both through', async () => {
// The in-flight guard used to read `isDownloadingVideo` out of the closure the callback
// was created in, so two calls made from the SAME render (a double click landing before
// React commits the state update) both got through and the file was fetched twice. It
// reads a ref now.
it('refuses a second call from the same render', async () => {
const startDownload = renderDownload().result.current.startDownload;
await act(async () => {
await Promise.all([startDownload(), startDownload()]);
});
expect(urlsFetched().filter((url) => url.includes('prepare=1'))).toHaveLength(2);
expect(urlsFetched().filter((url) => url.includes('prepare=1'))).toHaveLength(1);
expect(clicked).toHaveLength(1);
});
it('is ready to download again after a failure', async () => {
@@ -522,18 +522,18 @@ describe('useVersionActions uploading a file to Bunny', () => {
expect(callsTo(BUNNY_INIT_URL, 'DELETE')).toHaveLength(0);
});
// BUG, pinned rather than fixed. bunny-init has already created a video on
// Bunny by the time tus runs, but `pendingCleanup` is only assigned after
// uploadNewVersionFile returns. A tus failure therefore leaks that video:
// nothing ever calls the DELETE branch below it in the catch.
it('leaks the Bunny video when the tus upload itself fails', async () => {
// bunny-init has already created a video on Bunny by the time tus runs. `pendingCleanup`
// used to be assigned only after uploadNewVersionFile returned, so a tus failure threw
// past the assignment and left that video behind: billed, and invisible in the app. It
// is registered as soon as bunny-init answers now.
it('deletes the Bunny video when the tus upload itself fails', async () => {
tusFailure = 'connection reset';
const harness = renderVersionActions({ directUploadsEnabled: true });
await createFromFile(harness);
expect(toastError).toHaveBeenCalledWith('Upload failed: connection reset');
expect(callsTo(BUNNY_INIT_URL, 'DELETE')).toHaveLength(0);
expect(callsTo(BUNNY_INIT_URL, 'DELETE')).toHaveLength(1);
});
});
+33 -5
View File
@@ -580,7 +580,7 @@ describe('useVideoAssets deleting', () => {
expect(callsTo(`/api/videos/${VIDEO_ID}/assets/a1`, 'DELETE')).toHaveLength(1);
expect(deleted).toBe(true);
expect(assetIds(harness)).toEqual(['a2']);
expect(harness.result.current.activeDeleteAssetId).toBeNull();
expect(harness.result.current.deletingAssetIds).toEqual([]);
});
it('marks which row is being deleted while the request runs', async () => {
@@ -592,13 +592,13 @@ describe('useVideoAssets deleting', () => {
act(() => {
removal = harness.result.current.deleteAsset('a1');
});
expect(harness.result.current.activeDeleteAssetId).toBe('a1');
expect(harness.result.current.deletingAssetIds).toEqual(['a1']);
await act(async () => {
pending.resolve(jsonResponse(true, {}));
await removal;
});
expect(harness.result.current.activeDeleteAssetId).toBeNull();
expect(harness.result.current.deletingAssetIds).toEqual([]);
});
it('keeps the row when the server refuses the delete', async () => {
@@ -615,7 +615,7 @@ describe('useVideoAssets deleting', () => {
expect(deleted).toBe(false);
expect(assetIds(harness)).toEqual(['a1']);
expect(toastError).toHaveBeenCalledWith('Only the uploader can delete');
expect(harness.result.current.activeDeleteAssetId).toBeNull();
expect(harness.result.current.deletingAssetIds).toEqual([]);
});
it('keeps the row when the delete throws', async () => {
@@ -644,7 +644,35 @@ describe('useVideoAssets deleting', () => {
});
expect(assetIds(harness)).toEqual([]);
expect(harness.result.current.activeDeleteAssetId).toBeNull();
expect(harness.result.current.deletingAssetIds).toEqual([]);
});
// A single slot meant the second delete cleared the first one's spinner, so the first
// row stopped indicating progress while its request was still in flight.
it('marks both rows while two deletes overlap', async () => {
listed = listResponse({ assets: [makeAsset(), makeAsset({ id: 'a2' })] });
const harness = await renderAssets();
const first = deferred<unknown>();
const second = deferred<unknown>();
fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
let removals: Promise<boolean[]> | undefined;
act(() => {
removals = Promise.all([
harness.result.current.deleteAsset('a1'),
harness.result.current.deleteAsset('a2'),
]);
});
expect(harness.result.current.deletingAssetIds).toEqual(['a1', 'a2']);
await act(async () => {
first.resolve(jsonResponse(true, {}));
second.resolve(jsonResponse(true, {}));
await removals;
});
expect(harness.result.current.deletingAssetIds).toEqual([]);
});
});
@@ -477,14 +477,14 @@ describe('useVideoPageData loading tags', () => {
expect(harness.result.current.selectedTagId).toBe('tag-colour');
});
// KNOWN INEFFICIENCY, pinned rather than fixed. selectedTagId is in the
// effect's dependency list purely so the auto-select can read it, so the
// moment the first tag is selected the whole effect re-runs and the tag list
// is fetched a second time on every page load.
it('reads the tag list twice because selecting a tag re-runs the effect', async () => {
await renderPage();
// selectedTagId used to be in the effect's dependency list purely so the auto-select
// could read it, so the moment the first tag was selected the whole effect re-ran and
// the tag list was fetched a second time on every page load. It is read from a ref now.
it('reads the tag list once even though the auto-select sets a tag', async () => {
const harness = await renderPage();
expect(callsMatching((url) => url === TAGS_URL)).toHaveLength(2);
expect(harness.result.current.selectedTagId).toBe('tag-audio');
expect(callsMatching((url) => url === TAGS_URL)).toHaveLength(1);
});
it('selects nothing when the project has no tags', async () => {
+8 -9
View File
@@ -21,13 +21,13 @@ vi.mock('next/link', () => ({
let fetchMock: ReturnType<typeof vi.fn>;
/**
* ACCESSIBILITY FINDING: the password field has no <label>, no aria-label and
* no aria-labelledby, only a placeholder. A password input has no ARIA role
* either, so there is no `getByRole` route to it at all. Reported, not papered
* over: this helper documents that the placeholder is the only handle we have.
* A password input has no ARIA role, so `getByRole` cannot reach it whatever the
* markup does. `getByLabelText` can, and it only works because the field now has a
* visually hidden <label> associated by id: it used to have no label, no aria-label and
* no aria-labelledby, which left the placeholder as the only handle anything had.
*/
function passwordField() {
return screen.getByPlaceholderText('Password');
return screen.getByLabelText('Password');
}
beforeEach(() => {
@@ -177,14 +177,13 @@ describe('ShareLinkUnlock', () => {
render(<ShareLinkUnlock videoId="vid1" />);
await userEvent.type(passwordField(), 'hunter2');
// Capture the node first: while submitting, the label is swapped for a
// spinner, which leaves the button with no accessible name to query by.
// ACCESSIBILITY FINDING, reported rather than worked around.
const submit = screen.getByRole('button', { name: 'Continue' });
await userEvent.click(submit);
expect(submit).toBeDisabled();
expect(submit).toHaveAccessibleName('');
// The spinner that replaces the label carries a visually hidden name, so the button
// stays findable and announceable while it submits.
expect(submit).toHaveAccessibleName('Unlocking');
release({ ok: true, json: () => Promise.resolve({}) });
await waitFor(() => expect(replace).toHaveBeenCalledTimes(1));
+14
View File
@@ -0,0 +1,14 @@
// Per-file setup for the `unit` Vitest project.
//
// One job: put the environment back after every test. The unit project had no setup file
// at all, so each env-stubbing test had to restore its own state, and a forgotten
// `afterEach` leaves the next test reading a value it never set. That is the failure mode
// where a test passes for the wrong reason, which is worse than one that fails.
//
// Restoring centrally does not stop a test from calling `vi.unstubAllEnvs()` itself; it
// only makes forgetting harmless.
import { afterEach, vi } from 'vitest';
afterEach(() => {
vi.unstubAllEnvs();
});
+5 -3
View File
@@ -304,13 +304,15 @@ describe('verifyBunnyUploadToken', () => {
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(true);
});
it('returns false rather than throwing when the server has no secret configured', () => {
// A missing signing secret is a configuration fault, not a forgery. Answering "invalid
// token" for it turned a self-hosted misconfiguration into a silent, total upload
// outage that reads like a client bug.
it('throws rather than reporting a forgery when the server has no secret configured', () => {
const token = createBunnyUploadToken(SUBJECT);
vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', undefined);
vi.stubEnv('NEXTAUTH_SECRET', undefined);
// A misconfigured server is indistinguishable from a forged token here.
expect(verifyBunnyUploadToken(token, SUBJECT)).toBe(false);
expect(() => verifyBunnyUploadToken(token, SUBJECT)).toThrow();
});
});
@@ -4,6 +4,7 @@ import {
getPartByteRange,
getRetryDelayMs,
getUploadProgressPercent,
isRetryableUploadError,
PART_RETRY_DELAYS_MS,
} from '@/lib/client/upload-chunking';
@@ -184,4 +185,48 @@ describe('getMultipartProgressPercent', () => {
it('counts progress against the whole file, not the part', () => {
expect(getMultipartProgressPercent([100, 0, 0], 300)).toBe(33);
});
// Dividing by a total of zero produced NaN, which reached the UI as
// "Uploading... NaN%". Not reachable from the product today (r2-init rejects
// sizeBytes <= 0 and the multipart path only engages above 90 MiB), so the guard is
// here to keep an arithmetic accident from becoming a visible one.
it.each([
['zero', 0],
['a negative total', -1],
])('reports 0 rather than NaN for %s', (_label, totalBytes) => {
expect(getMultipartProgressPercent([0, 0], totalBytes)).toBe(0);
expect(getMultipartProgressPercent([50, 50], totalBytes)).toBe(0);
expect(getUploadProgressPercent(50, totalBytes)).toBe(0);
});
});
describe('isRetryableUploadError', () => {
// The retry loop used to repeat every rejection. Cancelling an upload therefore did
// not cancel it: the part sat through the full 2s, 5s and 10s backoff and fired three
// more PUTs before the error surfaced.
it('refuses to retry the user cancelling the upload', () => {
expect(isRetryableUploadError(new Error('Upload aborted'))).toBe(false);
});
// An expired presigned part URL answers 403 every time, so retrying turned one dead
// part into four requests and 17 seconds of apparent hanging.
it.each([400, 401, 403, 404, 411, 413])('refuses to retry status %s', (status) => {
expect(isRetryableUploadError(new Error(`Upload failed with status ${status}`))).toBe(false);
expect(isRetryableUploadError(new Error(`Chunk upload failed with status ${status}`))).toBe(
false
);
});
it.each([408, 429, 500, 502, 503, 504])('retries status %s', (status) => {
expect(isRetryableUploadError(new Error(`Upload failed with status ${status}`))).toBe(true);
});
it('retries an error that carries no status at all', () => {
expect(isRetryableUploadError(new Error('Network error during upload.'))).toBe(true);
expect(isRetryableUploadError(new Error('Upload response missing ETag header.'))).toBe(true);
});
it('retries a non-Error rejection rather than swallowing it', () => {
expect(isRetryableUploadError('something went wrong')).toBe(true);
});
});
+5 -3
View File
@@ -327,9 +327,11 @@ describe('buildCommentsCsv', () => {
expect(line[17]).toBe('"false"');
});
it('neutralises a negative timestamp because it starts with a minus sign', () => {
// Documents an interaction between the formula guard and numeric cells.
expect(csvRows([row({ timestamp: -1 })])[1][8]).toBe(`"'-1.000"`);
// The formula guard prefixes an apostrophe to anything starting with =, +, - or @.
// Applying it to a plain negative number stopped the spreadsheet reading the cell as a
// number at all, which is what a negative timestamp is.
it('leaves a negative number readable as a number', () => {
expect(csvRows([row({ timestamp: -1 })])[1][8]).toBe(`"-1.000"`);
});
it('preserves the flattened thread order in the output', () => {
+19 -1
View File
@@ -133,13 +133,31 @@ describe('buildContentSecurityPolicy', () => {
expect(mediaSrc).not.toContain('https://public.b-cdn.net');
});
it('always allows the local MinIO defaults in connect-src', () => {
it('allows the local MinIO defaults in connect-src outside production', () => {
vi.stubEnv('NODE_ENV', 'development');
const connectSrc = directives()['connect-src'];
expect(connectSrc).toContain('http://localhost:9000');
expect(connectSrc).toContain('http://127.0.0.1:9000');
});
// They are a local development convenience, and allowing plaintext loopback object
// storage in every deployment weakened the policy for a case production never has.
it('drops the local MinIO defaults from connect-src in production', () => {
vi.stubEnv('NODE_ENV', 'production');
const connectSrc = directives()['connect-src'];
expect(connectSrc).not.toContain('http://localhost:9000');
expect(connectSrc).not.toContain('http://127.0.0.1:9000');
});
it('still allows a loopback R2_ENDPOINT in production when one is configured', () => {
vi.stubEnv('NODE_ENV', 'production');
vi.stubEnv('R2_ENDPOINT', 'http://127.0.0.1:9000');
expect(directives()['connect-src']).toContain('http://127.0.0.1:9000');
});
it('reduces a custom R2 endpoint to its origin', () => {
vi.stubEnv('R2_ENDPOINT', 'https://minio.internal:9443/openframe-bucket');
+43 -7
View File
@@ -8,6 +8,7 @@ import {
emailRow,
escapeAttr,
escapeHtml,
rawEmailHtml,
} from '@/lib/email-brand';
describe('escapeHtml', () => {
@@ -30,10 +31,10 @@ describe('escapeHtml', () => {
expect(escapeHtml('&lt;')).toBe('&amp;lt;');
});
it('leaves a single quote unescaped', () => {
// Documents the current behaviour: values interpolated into single-quoted
// attributes are not protected by this helper.
expect(escapeHtml("it's")).toBe("it's");
// Single-quoted attributes exist in the templates, so leaving the quote alone left a
// value able to close one.
it('escapes the single quote', () => {
expect(escapeHtml("it's")).toBe('it&#39;s');
});
it('leaves plain text untouched', () => {
@@ -152,12 +153,47 @@ describe('email fragment builders', () => {
expect(highlighted).not.toContain(EMAIL_COLORS.textSecondary);
});
it('emailButton escapes the href but not the label', () => {
it('emailButton escapes both the href and the label', () => {
const html = emailButton('<b>Open</b>', 'https://x.com" onclick="alert(1)');
expect(html).toContain('&quot; onclick=&quot;alert(1)');
// Documents that the label is inserted raw, so callers must escape it.
expect(html).toContain('<b>Open</b>');
expect(html).toContain('&lt;b&gt;Open&lt;/b&gt;');
expect(html).not.toContain('<b>Open</b>');
});
// The escaping lives in the helpers rather than in every call site, so a project name
// or a display name is safe whether or not the next caller remembers to escape it.
it.each([
['emailHeading title', () => emailHeading('*', '<script>alert(1)</script>')],
['emailRow label', () => emailRow('<script>alert(1)</script>', 'value')],
['emailRow value', () => emailRow('label', '<script>alert(1)</script>')],
['emailHighlight text', () => emailHighlight('<script>alert(1)</script>')],
['emailButton label', () => emailButton('<script>alert(1)</script>', 'https://x.test')],
])('%s is escaped', (_label, build) => {
const html = build();
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
});
it('rawEmailHtml opts a value out of escaping', () => {
const html = emailRow('From', rawEmailHtml('<span>Alice</span>'));
expect(html).toContain('<span>Alice</span>');
});
it('escapes the footer text', () => {
const html = brandedEmailTemplate('<tr><td>body</td></tr>', {
footerText: '<script>alert(1)</script>',
});
expect(html).not.toContain('<script>');
});
it('inserts the body markup verbatim', () => {
const html = brandedEmailTemplate('<tr><td>body</td></tr>');
expect(html).toContain('<tr><td>body</td></tr>');
});
it('emailHighlight wraps the text in a bordered block', () => {
+33 -6
View File
@@ -179,17 +179,44 @@ describe('logError', () => {
});
});
// Documents a real limitation rather than an intended behaviour: the branch
// keys on the constructor name, so an error that only claims to be a Prisma
// error through `err.name` (a re-thrown, deserialised or minified one) falls
// through to the generic branch and its message is logged verbatim.
it('does not redact an error that is Prisma only by its `name` property', () => {
// An Error instance always has a constructor, so keying on `constructor.name` alone
// would stop redacting the moment an error identifies itself as Prisma only through
// `name`: one that was re-thrown or deserialised and lost its prototype, or a
// production build whose minifier renamed the class.
it('redacts an error that is Prisma only by its `name` property', () => {
const err = new Error(LEAKY_PRISMA_MESSAGE);
err.name = 'PrismaClientKnownRequestError';
(err as unknown as Record<string, unknown>).code = 'P2002';
logError('user lookup failed', err);
expect(loggedPayload()).toEqual({ type: 'Error', message: LEAKY_PRISMA_MESSAGE });
expect(loggedPayload()).toEqual({
type: 'PrismaError',
code: 'P2002',
message: 'Database error [P2002]',
});
});
it('redacts a name-only Prisma error that carries no code', () => {
const err = new Error(LEAKY_PRISMA_MESSAGE);
err.name = 'PrismaClientValidationError';
logError('user lookup failed', err);
expect(loggedPayload()).toEqual({
type: 'PrismaError',
code: 'UNKNOWN',
message: 'Database error [UNKNOWN]',
});
});
it('leaves a non-Prisma error alone', () => {
const err = new Error('plain failure');
err.name = 'ValidationError';
logError('lookup failed', err);
expect(loggedPayload()).toEqual({ type: 'Error', message: 'plain failure' });
});
});
+58 -28
View File
@@ -346,14 +346,21 @@ describe('validateProjectDownloadManifest', () => {
);
});
// KNOWN BUG in lib/project-download.ts, asserted as-is rather than fixed here:
// `BigInt(manifest.totalBytes)` is unguarded, so a non-numeric total throws a
// SyntaxError out of a function whose contract is to return a message string.
// The route wraps this in a try/catch and turns it into a 500 rather than the
// 400 that every other rejection produces.
it('throws instead of returning a message when totalBytes is not numeric', () => {
expect(() => validateProjectDownloadManifest(manifestOf({ totalBytes: 'lots' }))).toThrow(
SyntaxError
// The contract is to return a message, never to throw: a SyntaxError out of here
// reaches the route as a 500 rather than the 400 every other rejection produces.
it('returns a message rather than throwing when totalBytes is not numeric', () => {
expect(validateProjectDownloadManifest(manifestOf({ totalBytes: 'lots' }))).toBe(
'Could not determine the size of this download'
);
});
it.each([
['a negative total', '-1'],
['a decimal total', '1.5'],
['a hex total', '0x10'],
])('rejects %s without throwing', (_label, totalBytes) => {
expect(validateProjectDownloadManifest(manifestOf({ totalBytes }))).toBe(
'Could not determine the size of this download'
);
});
});
@@ -707,26 +714,38 @@ describe('buildProjectDownloadManifest provider routing', () => {
]);
});
// KNOWN BUG in lib/project-download.ts, asserted as-is rather than fixed here:
// the r2 branch returns `originalUrl` verbatim after a `startsWith` check on
// the proxy prefix, while the sibling branch below it validates the same shape
// against a strict UUID pattern. A stored url with dot segments is handed back
// untouched, and the extension the file name is built from is taken from the
// raw url too, so the resulting `fileName` escapes the archive root.
it('passes an r2 traversal path through and lets it leak into the file name', () => {
// The r2 branch validates against the strict proxy-path pattern rather than a
// `startsWith` on the prefix, so dot segments never reach the manifest as a url
// and never leak a path separator into the file name either.
it.each([
['dot segments after a valid-looking name', '/api/upload/video/clip.mp4/../../../etc/passwd'],
['a non-uuid basename', '/api/upload/video/clip.mp4'],
['an encoded traversal', '/api/upload/video/..%2F..%2Fetc%2Fpasswd'],
['a nested path', '/api/upload/video/nested/dir/file.mp4'],
])('drops an r2 version whose stored url has %s', (_label, originalUrl) => {
const manifest = buildProjectDownloadManifest('Project', [
video({ versions: [version({ providerId: 'r2', originalUrl })] }),
]);
expect(manifest.files).toEqual([]);
});
it('keeps a well-formed r2 proxy path', () => {
const manifest = buildProjectDownloadManifest('Project', [
video({
versions: [
version({
providerId: 'r2',
originalUrl: '/api/upload/video/clip.mp4/../../../../etc/passwd',
originalUrl: '/api/upload/video/bbbbbbbb-1111-2222-3333-444444444444.mp4',
}),
],
}),
]);
expect(manifest.files[0]?.url).toBe('/api/upload/video/clip.mp4/../../../../etc/passwd');
expect(manifest.files[0]?.fileName).toBe('01-Intro-v1./etc/passwd');
expect(manifest.files[0]?.url).toBe(
'/api/upload/video/bbbbbbbb-1111-2222-3333-444444444444.mp4'
);
expect(manifest.files[0]?.fileName).toBe('01-Intro-v1.mp4');
});
});
@@ -872,10 +891,10 @@ describe('buildProjectDownloadManifest file naming', () => {
).toEqual(['01-Intro-v1.mov']);
});
it('keeps the case of the extension', () => {
it('lowercases the extension', () => {
expect(
namesOf([video({ versions: [version({ originalUrl: 'https://cdn.example/master.MP4' })] })])
).toEqual(['01-Intro-v1.MP4']);
).toEqual(['01-Intro-v1.mp4']);
});
it('strips the query string before reading the extension', () => {
@@ -888,13 +907,11 @@ describe('buildProjectDownloadManifest file naming', () => {
).toEqual(['01-Intro-v1.webm']);
});
// KNOWN BUG in lib/project-download.ts, asserted as-is rather than fixed here:
// `extensionFromUrl` slices from the last dot anywhere in the url, including a
// dot in the host, and the result is appended after the sanitiser has already
// run. An allowlisted direct url with no file extension therefore produces a
// file name containing a path separator, which a zip writer turns into a
// directory rather than a file.
it('lets a dot in the host leak a path separator into the file name', () => {
// The extension is appended after the sanitiser has run, so it is derived from the
// last path segment only and has to be a short alphanumeric run. A dot in the host
// of an extensionless url must not contribute a path separator: a zip writer would
// turn that into a directory rather than a file.
it('falls back rather than letting a dot in the host leak a path separator', () => {
vi.stubEnv('NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS', 'example.com');
expect(
@@ -905,7 +922,20 @@ describe('buildProjectDownloadManifest file naming', () => {
],
}),
])
).toEqual(['01-Intro-v1.com/download']);
).toEqual(['01-Intro-v1.mp4']);
});
it.each([
['a path segment after the extension', 'https://example.com/a.mp4/../../etc/passwd'],
['an extension longer than ten characters', 'https://example.com/clip.verylongextension'],
['a non-alphanumeric extension', 'https://example.com/clip.mp4%2f..'],
['a dotfile with no extension', 'https://example.com/.hidden'],
])('falls back to .mp4 for %s', (_label, originalUrl) => {
vi.stubEnv('NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS', 'example.com');
expect(
namesOf([video({ versions: [version({ providerId: 'direct', originalUrl })] })])
).toEqual(['01-Intro-v1.mp4']);
});
it('falls back to .mp4 when the url contains no dot at all', () => {
+30 -15
View File
@@ -26,8 +26,11 @@ vi.mock('@/lib/r2', () => ({
import { proxyR2MediaObject } from '@/lib/r2-media-proxy';
/** A stored media key as the routes build one: a prefix plus a uuid file name. */
const SAFE_KEY = 'images/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png';
const BASE_OPTIONS = {
key: 'images/photo.png',
key: SAFE_KEY,
fallbackContentType: 'image/png',
cacheControl: 'private, no-store',
internalErrorMessage: 'Failed to retrieve image',
@@ -83,25 +86,37 @@ describe('key handling', () => {
await proxyR2MediaObject({ ...BASE_OPTIONS, request: request() });
expect(commandInput()).toMatchObject({ Bucket: 'test-bucket', Key: 'images/photo.png' });
expect(commandInput()).toMatchObject({ Bucket: 'test-bucket', Key: SAFE_KEY });
});
// Pinning the absence of validation, not endorsing it. This module applies no
// normalisation and no prefix check to `key`, so a caller that builds one from
// unvalidated input hands the traversal straight to S3. Today all three call
// sites gate the filename on a UUID regex first, which is the only reason this
// is not reachable. If a fourth route ever skips that regex, nothing in this
// module will stop it. See the report accompanying this suite.
it('passes a traversal-shaped key through untouched', async () => {
// The guard lives here rather than in each caller, so it travels with the function. All
// three call sites gate the file name on a uuid pattern first; a fourth that forgot
// would otherwise hand the traversal straight to GetObject.
it.each([
['a traversal segment', 'images/../../etc/passwd'],
['a nested path', 'images/nested/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png'],
['a non-uuid basename', 'images/photo.png'],
['an unknown prefix', 'secrets/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png'],
['no prefix at all', 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png'],
['a trailing segment', 'images/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png/../x'],
['an empty key', ''],
])('refuses %s with a 400 and never reaches storage', async (_label, key) => {
const response = await proxyR2MediaObject({ ...BASE_OPTIONS, key, request: request() });
expect(response.status).toBe(400);
expect(sendMock).not.toHaveBeenCalled();
});
it.each([
['images/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1.png'],
['voice/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2.webm'],
['videos/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee3.mp4'],
])('accepts the stored key shape %s', async (key) => {
sendMock.mockResolvedValue(objectWith());
await proxyR2MediaObject({
...BASE_OPTIONS,
key: 'images/../../etc/passwd',
request: request(),
});
await proxyR2MediaObject({ ...BASE_OPTIONS, key, request: request() });
expect(commandInput().Key).toBe('images/../../etc/passwd');
expect(commandInput().Key).toBe(key);
});
it('sends no Range or conditional fields when the request has no range header', async () => {
+6 -3
View File
@@ -346,14 +346,17 @@ describe('verifyR2UploadToken', () => {
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
});
it('returns false rather than throwing when the server has no secret configured', () => {
// A missing signing secret is a configuration fault, not a forgery. Answering "invalid
// token" for it turned a self-hosted misconfiguration into a silent, total upload
// outage that reads like a client bug.
it('throws rather than reporting a forgery when the server has no secret configured', () => {
const token = createR2UploadToken(SUBJECT);
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', undefined);
vi.stubEnv('NEXTAUTH_SECRET', undefined);
// A misconfigured server is indistinguishable from a forged token here.
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
expect(() => verifyR2UploadToken(token, SUBJECT)).toThrow();
expect(() => parseR2UploadToken(token)).toThrow();
});
});
+45 -15
View File
@@ -284,6 +284,21 @@ describe('createPresignedVideoPutUrl', () => {
expect(url.searchParams.get('X-Amz-SignedHeaders')?.split(';')).toContain('content-length');
});
// Passing ContentType to the command does not bind it. Without the header in the
// signature the holder of the url could put any media type at the key.
it('binds the content type into the signature', async () => {
const url = new URL(await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024)));
expect(url.searchParams.get('X-Amz-SignedHeaders')?.split(';')).toContain('content-type');
});
it('produces a different signature for a different content type', async () => {
const a = new URL(await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024)));
const b = new URL(await createPresignedVideoPutUrl(VIDEO_KEY, 'video/webm', BigInt(1024)));
expect(a.searchParams.get('X-Amz-Signature')).not.toBe(b.searchParams.get('X-Amz-Signature'));
});
it('produces a different signature for a different key', async () => {
const a = new URL(await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024)));
const b = new URL(
@@ -316,13 +331,19 @@ describe('createPresignedImagePutUrl', () => {
expect(url.searchParams.get('X-Amz-Expires')).toBe('3600');
});
// Documents current behaviour rather than endorsing it: ContentType is passed
// to the command but the presigner does not sign it, so the grant does not
// pin the uploaded media type. See the note in the review notes.
it('does not bind the content type into the signature', async () => {
// The image grant used to sign the host alone, so whoever held the url could put any
// media type at an `images/` key the app then went on serving as an image.
it('binds the content type into the signature', async () => {
const url = new URL(await createPresignedImagePutUrl('images/avatar.png', 'image/png'));
expect(url.searchParams.get('X-Amz-SignedHeaders')).toBe('host');
expect(url.searchParams.get('X-Amz-SignedHeaders')?.split(';')).toContain('content-type');
});
it('produces a different signature for a different content type', async () => {
const a = new URL(await createPresignedImagePutUrl('images/avatar.png', 'image/png'));
const b = new URL(await createPresignedImagePutUrl('images/avatar.png', 'image/webp'));
expect(a.searchParams.get('X-Amz-Signature')).not.toBe(b.searchParams.get('X-Amz-Signature'));
});
});
@@ -586,15 +607,24 @@ describe('deleteVideoObject and deleteR2Object', () => {
expect(inputAt(0)).toEqual({ Bucket: BUCKET, Key: 'images/a.png' });
});
// uploadAudio() writes under `voice/`, so the allowlist has to include it. Leaving it
// out meant a voice note could never be deleted by the module that stored it, and it
// outlived the comment it was attached to.
it('deletes a voice key, which uploadAudio writes', async () => {
await deleteR2Object('voice/note.webm');
expect(inputAt(0)).toEqual({ Bucket: BUCKET, Key: 'voice/note.webm' });
});
// The allowlist is the whole safety story for delete: anything that is not a
// video or an image key must never reach DeleteObject.
// video, image or voice key must never reach DeleteObject.
it.each([
'voice/note.webm',
'',
'/videos/a.mp4',
'other/videos/a.mp4',
'../videos/a.mp4',
'videos',
'other/voice/a.webm',
])('refuses to delete %s', async (key) => {
await expect(deleteR2Object(key)).rejects.toThrow('Invalid object key');
expect(send).not.toHaveBeenCalled();
@@ -728,22 +758,22 @@ describe('ensureR2UploadCors', () => {
});
});
// The try block wraps the write as well as the read, so a write that fails
// lands in the same catch as "no config to read" and the retry re-sends only
// the managed rule. Asserted as-is; see the review notes.
it('drops the pre-existing rules when the first write fails and the retry succeeds', async () => {
// The catch covers the read only. Wrapping the write in it too meant a failed write was
// mistaken for "no config to read", and the retry then replaced the bucket's existing
// rules with the managed one alone.
it('propagates a failed write rather than retrying without the pre-existing rules', async () => {
const existing = { AllowedOrigins: ['https://other.example.com'], AllowedMethods: ['GET'] };
send
.mockResolvedValueOnce({ CORSRules: [existing] } as never)
.mockRejectedValueOnce(s3Error(500))
.mockResolvedValueOnce({} as never);
await ensureR2UploadCors();
await expect(ensureR2UploadCors()).rejects.toThrow();
expect(send).toHaveBeenCalledTimes(3);
expect(inputAt(2)).toEqual({
expect(send).toHaveBeenCalledTimes(2);
expect(inputAt(1)).toEqual({
Bucket: BUCKET,
CORSConfiguration: { CORSRules: [managedRule] },
CORSConfiguration: { CORSRules: [existing, managedRule] },
});
});
+67 -11
View File
@@ -1,3 +1,4 @@
import { createHash } from 'crypto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
RATE_LIMIT_CONFIGS,
@@ -18,6 +19,17 @@ function requestWith(headers: Record<string, string>): Request {
return new Request('https://example.com/api/comments', { headers });
}
/**
* The interpolated values of the most recent `$queryRaw` tagged template, in order:
* the stored key, the stored action, then the window length twice.
*/
function valuesOfLastQuery(): unknown[] {
const calls = dbMock.$queryRaw.mock.calls;
const last = calls[calls.length - 1];
if (!last) throw new Error('no $queryRaw call was recorded');
return last.slice(1);
}
beforeEach(() => {
vi.stubEnv('TRUSTED_PROXY_MODE', undefined);
vi.stubEnv('DISABLE_RATE_LIMIT', undefined);
@@ -221,24 +233,68 @@ describe('checkRateLimit', () => {
expect(dbMock.$queryRaw).toHaveBeenCalledTimes(1);
});
it('skips the query for an over-long key', async () => {
const result = await checkRateLimit('k'.repeat(257), 'comment');
expect(result.allowed).toBe(true);
expect(dbMock.$queryRaw).not.toHaveBeenCalled();
});
it('queries for a key at exactly the 256 character limit', async () => {
// A key wider than rate_limits.key used to be skipped, which meant no limit applied at
// all. It is hashed instead, so the query still runs and the caller is still counted.
it('hashes a key wider than the column instead of skipping the query', async () => {
dbMock.$queryRaw.mockResolvedValue(rowsWithCount(1));
await checkRateLimit('k'.repeat(256), 'comment');
expect(dbMock.$queryRaw).toHaveBeenCalledTimes(1);
const storedKey = valuesOfLastQuery()[0] as string;
expect(storedKey).toBe(createHash('sha256').update('k'.repeat(256)).digest('hex'));
expect(storedKey.length).toBeLessThanOrEqual(255);
});
it('skips the query for an over-long action', async () => {
await checkRateLimit('1.2.3.4', 'a'.repeat(65));
expect(dbMock.$queryRaw).not.toHaveBeenCalled();
it('gives the same over-long key the same bucket every time', async () => {
dbMock.$queryRaw.mockResolvedValue(rowsWithCount(1));
await checkRateLimit('k'.repeat(300), 'comment');
const first = valuesOfLastQuery()[0];
await checkRateLimit('k'.repeat(300), 'comment');
const second = valuesOfLastQuery()[0];
expect(first).toBe(second);
});
it('gives two different over-long keys different buckets', async () => {
dbMock.$queryRaw.mockResolvedValue(rowsWithCount(1));
await checkRateLimit(`a${'k'.repeat(300)}`, 'comment');
const first = valuesOfLastQuery()[0];
await checkRateLimit(`b${'k'.repeat(300)}`, 'comment');
const second = valuesOfLastQuery()[0];
expect(first).not.toBe(second);
});
it('passes a key that fits the column through untouched', async () => {
dbMock.$queryRaw.mockResolvedValue(rowsWithCount(1));
await checkRateLimit('k'.repeat(255), 'comment');
expect(valuesOfLastQuery()[0]).toBe('k'.repeat(255));
});
it('hashes an action wider than its narrower column', async () => {
dbMock.$queryRaw.mockResolvedValue(rowsWithCount(1));
await checkRateLimit('1.2.3.4', 'a'.repeat(51));
expect(dbMock.$queryRaw).toHaveBeenCalledTimes(1);
const storedAction = valuesOfLastQuery()[1] as string;
expect(storedAction).toBe(
createHash('sha256').update('a'.repeat(51)).digest('hex').slice(0, 50)
);
expect(storedAction.length).toBe(50);
});
it('passes an action that fits its column through untouched', async () => {
dbMock.$queryRaw.mockResolvedValue(rowsWithCount(1));
await checkRateLimit('1.2.3.4', 'comment');
expect(valuesOfLastQuery()[1]).toBe('comment');
});
it('reports the remaining budget and the reset instant from the stored window', async () => {
+57 -12
View File
@@ -403,6 +403,59 @@ describe('requireWorkspaceAccessOrRedirect', () => {
);
});
// The redirect target must not depend on the owner's billing for somebody with no
// relationship to the workspace, or it becomes an oracle: probe workspace ids, and
// /settings rather than /dashboard tells you whose subscription has lapsed.
it.each([
['the owner is paying', true],
['the owner has lapsed', false],
])('sends a signed-in stranger to the dashboard when %s', async (_label, ownerBillingActive) => {
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
authModule.checkWorkspaceAccess.mockResolvedValue(
workspaceAccess({ hasAccess: false, ownerBillingActive })
);
await expectRedirect(
requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID, userId: OTHER_USER_ID }),
FORBIDDEN
);
});
// A member cannot resolve the owner's billing from their own settings page, so sending
// them there offers no action they can take.
it('sends a member whose owner has lapsed to the dashboard, not to billing', async () => {
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
authModule.checkWorkspaceAccess.mockResolvedValue(
workspaceAccess({ isMember: true, hasAccess: false, ownerBillingActive: false })
);
await expectRedirect(
requireWorkspaceAccessOrRedirect({ workspaceId: WORKSPACE_ID, userId: OTHER_USER_ID }),
FORBIDDEN
);
});
it('sends the lapsed owner to billing on the manage intent too', async () => {
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
authModule.checkWorkspaceAccess.mockResolvedValue(
workspaceAccess({
isOwner: true,
hasAccess: true,
canEdit: false,
ownerBillingActive: false,
})
);
await expectRedirect(
requireWorkspaceAccessOrRedirect({
workspaceId: WORKSPACE_ID,
userId: USER_ID,
intent: 'manage',
}),
BILLING
);
});
it('sends a member who cannot edit to the dashboard when the page needs manage rights', async () => {
dbMock.workspace.findUnique.mockResolvedValue(WORKSPACE_ROW);
authModule.checkWorkspaceAccess.mockResolvedValue(
@@ -579,9 +632,7 @@ describe('requireProjectAccessOrRedirect', () => {
await expect(
requireProjectAccessOrRedirect({ projectId: PROJECT_ID, allowPublicView: true })
).resolves.toEqual({ project: PUBLIC_PROJECT_ROW, access });
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PUBLIC_PROJECT_ROW, undefined, {
intent: 'view',
});
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PUBLIC_PROJECT_ROW, undefined);
});
it('passes the manage intent down to the permission check', async () => {
@@ -596,9 +647,7 @@ describe('requireProjectAccessOrRedirect', () => {
intent: 'manage',
});
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, USER_ID, {
intent: 'manage',
});
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, USER_ID);
});
it('falls back to the session user when no id is passed', async () => {
@@ -610,9 +659,7 @@ describe('requireProjectAccessOrRedirect', () => {
await requireProjectAccessOrRedirect({ projectId: PROJECT_ID });
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, OTHER_USER_ID, {
intent: 'view',
});
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, OTHER_USER_ID);
});
});
@@ -697,9 +744,7 @@ describe('requireVideoProjectAccessOrRedirect', () => {
await expect(
requireVideoProjectAccessOrRedirect({ ...args, userId: OTHER_USER_ID })
).resolves.toEqual({ video: VIDEO_ROW, project: PROJECT_ROW, access });
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, OTHER_USER_ID, {
intent: 'view',
});
expect(authModule.checkProjectAccess).toHaveBeenCalledWith(PROJECT_ROW, OTHER_USER_ID);
});
it('lets an anonymous viewer watch a video in a public project when the route opts in', async () => {
+10 -9
View File
@@ -108,16 +108,17 @@ describe('resolveVideoContentType', () => {
expect(resolveVideoContentType('payload.exe', 'application/x-msdownload')).toBeNull();
});
// KNOWN GAP asserted as-is: a client-declared video mime is accepted even when
// the file name is not a known video extension, because the mismatch branch only
// fires when BOTH sides resolve to an extension.
it('trusts a declared video mime even for a non-video file name', () => {
expect(resolveVideoContentType('payload.exe', 'video/mp4')).toBe('video/mp4');
expect(isAllowedVideoFile('payload.exe', 'video/mp4')).toBe(true);
});
// The declared mime is a client claim, so it cannot be what makes a file acceptable.
it.each(['payload.exe', 'payload', 'payload.', 'payload.mp4.exe'])(
'refuses %s however it declares itself',
(fileName) => {
expect(resolveVideoContentType(fileName, 'video/mp4')).toBeNull();
expect(isAllowedVideoFile(fileName, 'video/mp4')).toBe(false);
}
);
it('accepts a video mime that has no extension mapping of its own', () => {
expect(resolveVideoContentType('clip.mp4', 'video/3gpp')).toBe('video/3gpp');
it('ignores a video mime that has no extension mapping of its own', () => {
expect(resolveVideoContentType('clip.mp4', 'video/3gpp')).toBe('video/mp4');
});
});
+9 -7
View File
@@ -101,13 +101,15 @@ describe('validateAnnotationStrokes', () => {
expect(validateAnnotationStrokes([stroke({ width })])).toBeNull();
});
// KNOWN GAP in lib/validation.ts, asserted as-is rather than fixed here:
// the width check is `width < MIN || width > MAX`, and both comparisons are
// false for NaN, so NaN passes the bounds test. The coordinate checks use an
// explicit isFinite() guard; the width check does not. A NaN width survives
// into the stored annotation JSON, where JSON.stringify renders it as null.
it('lets a NaN stroke width through, unlike NaN coordinates', () => {
expect(validateAnnotationStrokes([stroke({ width: Number.NaN })])?.[0].width).toBeNaN();
// Both bounds comparisons are false for NaN, so the range check alone let it through
// into the stored annotation JSON, where JSON.stringify renders it as null. Coordinates
// always had the isFinite() guard the width was missing.
it.each([
['NaN', Number.NaN],
['Infinity', Number.POSITIVE_INFINITY],
['-Infinity', Number.NEGATIVE_INFINITY],
])('refuses a %s stroke width, as it does for coordinates', (_label, width) => {
expect(validateAnnotationStrokes([stroke({ width })])).toBeNull();
});
it.each(['#FF3B30', '#ff3b30', '#000000', '#AbCdEf'])('accepts colour %s', (color) => {
+11 -4
View File
@@ -258,13 +258,20 @@ describe('direct and r2 embed urls', () => {
expect(getEmbedUrl({ providerId: 'direct', videoId: url, originalUrl: url })).toBe(url);
});
// The direct provider floors the start time into the query params but then
// appends the unfloored value as the media fragment. Asserted as-is.
it('appends the unfloored start time as a media fragment for a direct url', () => {
// The fragment carries the floored value. It used to carry the unfloored one, which
// made the floor a step above it accomplish nothing.
it('appends the floored start time as a media fragment for a direct url', () => {
const url = 'https://cdn.example.com/clip.mp4';
expect(
getEmbedUrl({ providerId: 'direct', videoId: url, originalUrl: url }, { startTime: 30.5 })
).toBe(`${url}#t=30.5`);
).toBe(`${url}#t=30`);
});
it('appends no fragment when the start time floors to zero', () => {
const url = 'https://cdn.example.com/clip.mp4';
expect(
getEmbedUrl({ providerId: 'direct', videoId: url, originalUrl: url }, { startTime: 0.4 })
).toBe(url);
});
it('uses a query parameter rather than a fragment for an r2 proxy path', () => {
+12 -15
View File
@@ -23,25 +23,22 @@ describe('normalizeFrameRate', () => {
expect(normalizeFrameRate(24.9)).toBe(25);
});
it('returns an exact standard rate unchanged, except the NTSC-shadowed ones', () => {
for (const rate of [23.976, 25, 29.97, 48, 50, 59.94, 120]) {
it('returns an exact standard rate unchanged', () => {
for (const rate of [23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60, 120]) {
expect(normalizeFrameRate(rate)).toBe(rate);
}
});
// KNOWN PRODUCTION BUG, pinned rather than fixed. The tolerance is +/-1.5%
// but 23.976/24, 29.97/30 and 59.94/60 are only 0.1% apart, and the lookup
// takes the FIRST entry within tolerance rather than the closest. The NTSC
// rate always comes first in STANDARD_FRAME_RATES, so 24, 30 and 60 can
// never be returned: an exactly-30fps source is reported as 29.97fps, which
// is the very frame-count drift the snapping is meant to prevent (~18 frames
// off after 10 minutes).
it('mislabels exact 24, 30 and 60 fps as their NTSC neighbours', () => {
expect(normalizeFrameRate(24)).toBe(23.976);
expect(normalizeFrameRate(30)).toBe(29.97);
expect(normalizeFrameRate(60)).toBe(59.94);
// 30.07 is closer to 30 than to 29.97 and still loses.
expect(normalizeFrameRate(30.07)).toBe(29.97);
// The tolerance is 1.5 percent but the NTSC pairs are only 0.1 percent apart, so taking
// the first entry within tolerance made 24, 30 and 60 unreachable and reported an
// exactly 30 fps source as 29.97: the very drift the snapping exists to prevent.
it('picks the nearest standard rather than the first within tolerance', () => {
expect(normalizeFrameRate(30.07)).toBe(30);
expect(normalizeFrameRate(29.99)).toBe(30);
expect(normalizeFrameRate(29.96)).toBe(29.97);
expect(normalizeFrameRate(23.99)).toBe(24);
expect(normalizeFrameRate(59.98)).toBe(60);
expect(normalizeFrameRate(59.95)).toBe(59.94);
});
it('keeps a plausible non-standard rate rather than forcing a snap', () => {