mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Second pass over the suite, driven by the inventory in the gaps document. Nine agents wrote suites in parallel against private databases, then a tenth read all of it adversarially and five of its findings were fixed. unit + component 2076 -> 2079 (+888 over the round) api 647 -> 1015 e2e 18 -> 29 What was closed: - lib/route-access.ts, the page-level authorization layer, went from zero tests to 48. Every API route was guarded and none of the pages were. - The five media proxy routes now have a real 2xx beside every 403. The blocker was the positive control, solved by stubbing r2Client.send() and leaving lib/r2-media-proxy.ts itself real. - Every remaining server-side lib module: invitations, email verification, the upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle, notifications and admin stats. - Six video-page hooks, and the chunking arithmetic extracted out of lib/client/r2-video-upload.ts as a pure module. - Five end-to-end flows: workspace members, bulk operations, the admin area, player interaction and failure recovery. Three things about the harness itself turned out to be wrong: - Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every route reaching finalizeR2VideoUpload silently took the "not a valid video" branch and no test noticed. - The auth matrix asserted only "not 2xx", which two entries satisfied without their guard existing. It now requires 401 or 403, which makes both load-bearing, and all 60 routes pass the stricter form. - Both admin API routes had no positive control anywhere: replacing their guard with an unconditional refusal left the entire suite green. Found by the adversarial review, now covered. Process: - bun run test:mutation runs StrykerJS over the authorization and validation modules. Diagnostic, not a gate, weekly in CI rather than on a push. - playwright.config.ts gains an opt-in webkit project for the player spec. - AGENTS.md now requires a batch of new tests to be reviewed by somebody who did not write them. Only two production files change, both deliberate: lib/auth.ts loses a verbatim copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the extracted arithmetic. No behaviour change in either.
188 lines
7.0 KiB
TypeScript
188 lines
7.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
getMultipartProgressPercent,
|
|
getPartByteRange,
|
|
getRetryDelayMs,
|
|
getUploadProgressPercent,
|
|
PART_RETRY_DELAYS_MS,
|
|
} from '@/lib/client/upload-chunking';
|
|
|
|
const MIB = 1024 * 1024;
|
|
/** The S3 floor for a non-final part, and the smallest size the host can configure. */
|
|
const MIN_PART_SIZE = 5 * MIB;
|
|
/** The default `OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES`. */
|
|
const DEFAULT_PART_SIZE = 32 * MIB;
|
|
|
|
/**
|
|
* The part list is built by the r2-init route, which sizes it with a ceiling
|
|
* division over the same file length. Mirroring that here (rather than importing
|
|
* it) keeps these expectations independent of the module under test.
|
|
*/
|
|
function partNumbers(totalBytes: number, partSizeBytes: number): number[] {
|
|
const count = Math.ceil(totalBytes / partSizeBytes);
|
|
return Array.from({ length: count }, (_unused, index) => index + 1);
|
|
}
|
|
|
|
function rangesFor(totalBytes: number, partSizeBytes: number) {
|
|
return partNumbers(totalBytes, partSizeBytes).map((partNumber) =>
|
|
getPartByteRange(partNumber, partSizeBytes, totalBytes)
|
|
);
|
|
}
|
|
|
|
describe('PART_RETRY_DELAYS_MS', () => {
|
|
it('gives a failing part three retries over at most 17 seconds', () => {
|
|
expect(PART_RETRY_DELAYS_MS).toEqual([0, 2000, 5000, 10000]);
|
|
});
|
|
});
|
|
|
|
describe('getRetryDelayMs', () => {
|
|
it('runs the first attempt immediately', () => {
|
|
expect(getRetryDelayMs(0)).toBe(0);
|
|
});
|
|
|
|
it('backs off further on each retry', () => {
|
|
expect(getRetryDelayMs(1)).toBe(2000);
|
|
expect(getRetryDelayMs(2)).toBe(5000);
|
|
expect(getRetryDelayMs(3)).toBe(10000);
|
|
});
|
|
|
|
it('reads the delay from a caller-supplied schedule', () => {
|
|
expect(getRetryDelayMs(1, [0, 50])).toBe(50);
|
|
expect(getRetryDelayMs(2, [0, 50, 75])).toBe(75);
|
|
});
|
|
|
|
// Guards the `?? 0` fallback: without it the caller would await
|
|
// setTimeout(undefined), which fires immediately and turns a bounded backoff
|
|
// into a hot loop.
|
|
it('waits not at all past the end of the schedule', () => {
|
|
expect(getRetryDelayMs(4)).toBe(0);
|
|
expect(getRetryDelayMs(99)).toBe(0);
|
|
expect(getRetryDelayMs(-1)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('getPartByteRange', () => {
|
|
it('gives each part a full, non-overlapping slice', () => {
|
|
expect(getPartByteRange(1, MIN_PART_SIZE, 15 * MIB)).toEqual({ start: 0, end: 5 * MIB });
|
|
expect(getPartByteRange(2, MIN_PART_SIZE, 15 * MIB)).toEqual({
|
|
start: 5 * MIB,
|
|
end: 10 * MIB,
|
|
});
|
|
expect(getPartByteRange(3, MIN_PART_SIZE, 15 * MIB)).toEqual({
|
|
start: 10 * MIB,
|
|
end: 15 * MIB,
|
|
});
|
|
});
|
|
|
|
it('splits a file that lands exactly on a part boundary into whole parts', () => {
|
|
const ranges = rangesFor(15 * MIB, MIN_PART_SIZE);
|
|
|
|
expect(ranges).toHaveLength(3);
|
|
// No short tail: the last part is as long as the others and stops on the
|
|
// last byte of the file.
|
|
expect(ranges[2].end - ranges[2].start).toBe(MIN_PART_SIZE);
|
|
expect(ranges[2].end).toBe(15 * MIB);
|
|
});
|
|
|
|
it('gives one byte over a boundary its own one-byte part', () => {
|
|
const totalBytes = 15 * MIB + 1;
|
|
const ranges = rangesFor(totalBytes, MIN_PART_SIZE);
|
|
|
|
expect(ranges).toHaveLength(4);
|
|
expect(ranges[3]).toEqual({ start: 15 * MIB, end: totalBytes });
|
|
expect(ranges[3].end - ranges[3].start).toBe(1);
|
|
});
|
|
|
|
it('leaves the remainder to the final part when the file is one byte short', () => {
|
|
const totalBytes = 15 * MIB - 1;
|
|
const ranges = rangesFor(totalBytes, MIN_PART_SIZE);
|
|
|
|
expect(ranges).toHaveLength(3);
|
|
expect(ranges[2]).toEqual({ start: 10 * MIB, end: totalBytes });
|
|
expect(ranges[2].end - ranges[2].start).toBe(MIN_PART_SIZE - 1);
|
|
});
|
|
|
|
it('covers every byte of the file exactly once, whatever the remainder', () => {
|
|
for (const totalBytes of [
|
|
1,
|
|
MIN_PART_SIZE - 1,
|
|
MIN_PART_SIZE,
|
|
MIN_PART_SIZE + 1,
|
|
3 * MIN_PART_SIZE + 7,
|
|
DEFAULT_PART_SIZE * 4,
|
|
DEFAULT_PART_SIZE * 4 + 12345,
|
|
]) {
|
|
for (const partSize of [MIN_PART_SIZE, DEFAULT_PART_SIZE]) {
|
|
const ranges = rangesFor(totalBytes, partSize);
|
|
expect(ranges[0].start).toBe(0);
|
|
expect(ranges[ranges.length - 1].end).toBe(totalBytes);
|
|
for (let index = 1; index < ranges.length; index += 1) {
|
|
expect(ranges[index].start).toBe(ranges[index - 1].end);
|
|
}
|
|
const uploaded = ranges.reduce((sum, range) => sum + (range.end - range.start), 0);
|
|
expect(uploaded).toBe(totalBytes);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('still lands on the last byte at the 10000-part S3 ceiling', () => {
|
|
// 10000 parts of 5 MiB is the largest upload the smallest allowed part size
|
|
// can express, and the offsets there are past 2^35, so this is where an
|
|
// arithmetic slip would first show up as a truncated or duplicated part.
|
|
const totalBytes = 10000 * MIN_PART_SIZE;
|
|
const last = getPartByteRange(10000, MIN_PART_SIZE, totalBytes);
|
|
|
|
expect(last.start).toBe(9999 * MIN_PART_SIZE);
|
|
expect(last.end).toBe(totalBytes);
|
|
expect(Number.isSafeInteger(last.start)).toBe(true);
|
|
});
|
|
|
|
it('produces an empty range for a zero-byte file', () => {
|
|
// Unreachable today: r2-init rejects sizeBytes <= 0 before any part is
|
|
// presigned. Pinned because the arithmetic must not produce a negative
|
|
// length if that ever changes.
|
|
expect(getPartByteRange(1, MIN_PART_SIZE, 0)).toEqual({ start: 0, end: 0 });
|
|
});
|
|
|
|
it('reports a part past the end of the file as an empty slice, not a negative one', () => {
|
|
// A server that over-counted parts would send part 4 for a 15 MiB file.
|
|
// `end` below `start` is what Blob.slice reads as empty, so the request goes
|
|
// out with no bytes rather than with garbage.
|
|
const range = getPartByteRange(4, MIN_PART_SIZE, 15 * MIB);
|
|
expect(range.start).toBe(15 * MIB);
|
|
expect(range.end).toBe(15 * MIB);
|
|
});
|
|
});
|
|
|
|
describe('getUploadProgressPercent', () => {
|
|
it('reports whole percent through the upload', () => {
|
|
expect(getUploadProgressPercent(0, 200)).toBe(0);
|
|
expect(getUploadProgressPercent(50, 200)).toBe(25);
|
|
expect(getUploadProgressPercent(200, 200)).toBe(100);
|
|
});
|
|
|
|
it('rounds to the nearest percent rather than truncating', () => {
|
|
expect(getUploadProgressPercent(7, 1000)).toBe(1);
|
|
expect(getUploadProgressPercent(4, 1000)).toBe(0);
|
|
expect(getUploadProgressPercent(995, 1000)).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe('getMultipartProgressPercent', () => {
|
|
it('adds up the bytes reported by every part', () => {
|
|
expect(getMultipartProgressPercent([0, 0, 0], 300)).toBe(0);
|
|
expect(getMultipartProgressPercent([100, 50, 0], 300)).toBe(50);
|
|
expect(getMultipartProgressPercent([100, 100, 100], 300)).toBe(100);
|
|
});
|
|
|
|
it('never reports past 100 when a retried part double-counts', () => {
|
|
// A part that failed halfway and was retried has already reported those
|
|
// bytes once; without the clamp the bar would run past the end of the track.
|
|
expect(getMultipartProgressPercent([100, 100, 150], 300)).toBe(100);
|
|
});
|
|
|
|
it('counts progress against the whole file, not the part', () => {
|
|
expect(getMultipartProgressPercent([100, 0, 0], 300)).toBe(33);
|
|
});
|
|
});
|