mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
emailRow,
|
||||
escapeAttr,
|
||||
escapeHtml,
|
||||
rawEmailHtml,
|
||||
} from '@/lib/email-brand';
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
@@ -30,10 +31,10 @@ describe('escapeHtml', () => {
|
||||
expect(escapeHtml('<')).toBe('&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'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('" onclick="alert(1)');
|
||||
// Documents that the label is inserted raw, so callers must escape it.
|
||||
expect(html).toContain('<b>Open</b>');
|
||||
expect(html).toContain('<b>Open</b>');
|
||||
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('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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] },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user