mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 08:10:28 +09:00
사소한 리팩토링
This commit is contained in:
1 parent
d986493431
commit
48bf9c1165
62 files changed
+31
-39
No files matched your search
@@ -0,0 +1,56 @@
|
||||
import axios from "axios";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { uploadBlob } from "#lib/client/transfer/chunked-upload.ts";
|
||||
import { generateDataKey } from "#lib/shared/crypto/index.ts";
|
||||
import { CHUNK_SIZE } from "#lib/shared/format/index.ts";
|
||||
|
||||
vi.mock("axios", () => ({ default: { post: vi.fn() } }));
|
||||
|
||||
const post = vi.mocked(axios.post);
|
||||
|
||||
const createBlob = (chunks: number) => new Blob([new Uint8Array((chunks - 1) * CHUNK_SIZE + 1)]);
|
||||
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
post.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("uploadBlob", () => {
|
||||
it("uploads every chunk", async () => {
|
||||
const { dataKey } = await generateDataKey();
|
||||
|
||||
const { encContentHash } = await uploadBlob("upload", createBlob(3), dataKey);
|
||||
|
||||
expect(encContentHash).toBeTruthy();
|
||||
expect(post.mock.calls.map(([url]) => url).sort()).toEqual([
|
||||
"/api/upload/upload/chunks/1",
|
||||
"/api/upload/upload/chunks/2",
|
||||
"/api/upload/upload/chunks/3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects if encryption fails", async () => {
|
||||
const { dataKey } = await generateDataKey();
|
||||
const error = new Error("Encryption failed");
|
||||
|
||||
await expect(
|
||||
uploadBlob("upload", createBlob(2), dataKey, {
|
||||
onPlaintextChunk: async (chunkIndex) => {
|
||||
if (chunkIndex === 1) throw error;
|
||||
},
|
||||
}),
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it("rejects if uploading a chunk fails", async () => {
|
||||
const { dataKey } = await generateDataKey();
|
||||
const error = new Error("Upload failed");
|
||||
post.mockImplementation(async (url) => {
|
||||
if (url.endsWith("/2")) throw error;
|
||||
return {};
|
||||
});
|
||||
|
||||
// More chunks than the queue can hold, so that the producer would block if not stopped
|
||||
await expect(uploadBlob("upload", createBlob(16), dataKey)).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BoundedQueue, Scheduler } from "#lib/utils/concurrency/index.ts";
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe("BoundedQueue", () => {
|
||||
it("blocks producers while full", async () => {
|
||||
const queue = new BoundedQueue<number>(2);
|
||||
await queue.push(1);
|
||||
await queue.push(2);
|
||||
|
||||
let isPushed = false;
|
||||
const pushing = queue.push(3).then(() => (isPushed = true));
|
||||
await tick();
|
||||
expect(isPushed).toBe(false);
|
||||
|
||||
expect(await queue.pop()).toBe(1);
|
||||
await pushing;
|
||||
expect(isPushed).toBe(true);
|
||||
});
|
||||
|
||||
it("drains remaining items and then returns null after closing", async () => {
|
||||
const queue = new BoundedQueue<number>(2);
|
||||
await queue.push(1);
|
||||
queue.close();
|
||||
|
||||
expect(await queue.pop()).toBe(1);
|
||||
expect(await queue.pop()).toBeNull();
|
||||
await expect(queue.push(2)).rejects.toThrow("Queue closed");
|
||||
});
|
||||
|
||||
it("wakes up waiting consumers when closed", async () => {
|
||||
const queue = new BoundedQueue<number>(1);
|
||||
const popping = queue.pop();
|
||||
queue.close();
|
||||
expect(await popping).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler", () => {
|
||||
it("defers tasks while the memory limit is exceeded", async () => {
|
||||
const scheduler = new Scheduler<void>(100);
|
||||
const events: string[] = [];
|
||||
|
||||
let finishFirst!: () => void;
|
||||
const first = scheduler.schedule(100, async () => {
|
||||
events.push("first:start");
|
||||
await new Promise<void>((resolve) => (finishFirst = resolve));
|
||||
events.push("first:end");
|
||||
});
|
||||
const second = scheduler.schedule(10, async () => {
|
||||
events.push("second:start");
|
||||
});
|
||||
|
||||
await tick();
|
||||
expect(events).toEqual(["first:start"]);
|
||||
|
||||
finishFirst();
|
||||
await Promise.all([first, second]);
|
||||
expect(events).toEqual(["first:start", "first:end", "second:start"]);
|
||||
});
|
||||
|
||||
it("runs small tasks concurrently", async () => {
|
||||
const scheduler = new Scheduler<number>(100);
|
||||
let running = 0;
|
||||
let maxRunning = 0;
|
||||
|
||||
await Promise.all(
|
||||
[1, 2, 3].map((value) =>
|
||||
scheduler.schedule(10, async () => {
|
||||
maxRunning = Math.max(maxRunning, ++running);
|
||||
await tick();
|
||||
running--;
|
||||
return value;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(maxRunning).toBe(3);
|
||||
});
|
||||
|
||||
it("releases memory when a task fails", async () => {
|
||||
const scheduler = new Scheduler<void>(100);
|
||||
await expect(
|
||||
scheduler.schedule(100, async () => {
|
||||
throw new Error("Failed");
|
||||
}),
|
||||
).rejects.toThrow("Failed");
|
||||
|
||||
await expect(scheduler.schedule(100, async () => {})).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decodeFromBase64,
|
||||
decryptChunk,
|
||||
decryptString,
|
||||
encodeToBase64,
|
||||
encryptChunk,
|
||||
encryptString,
|
||||
generateDataKey,
|
||||
} from "#lib/shared/crypto/index.ts";
|
||||
|
||||
describe("base64", () => {
|
||||
it("round-trips large buffers", () => {
|
||||
const data = Uint8Array.from({ length: 1024 * 1024 }, (_, i) => (i * 31) % 256);
|
||||
const encoded = encodeToBase64(data);
|
||||
expect(new Uint8Array(decodeFromBase64(encoded))).toEqual(data);
|
||||
expect(encodeToBase64(data.buffer)).toBe(encoded);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AES-GCM", () => {
|
||||
it("round-trips strings", async () => {
|
||||
const { dataKey } = await generateDataKey();
|
||||
const { ciphertext, iv } = await encryptString("안녕하세요.jpg", dataKey);
|
||||
expect(await decryptString(ciphertext, iv, dataKey)).toBe("안녕하세요.jpg");
|
||||
});
|
||||
|
||||
it("round-trips chunks", async () => {
|
||||
const { dataKey } = await generateDataKey();
|
||||
const chunk = crypto.getRandomValues(new Uint8Array(4096));
|
||||
|
||||
const encrypted = await encryptChunk(chunk.buffer, dataKey);
|
||||
|
||||
expect(encrypted.byteLength).toBe(4096 + 12 + 16);
|
||||
expect(new Uint8Array(await decryptChunk(encrypted, dataKey))).toEqual(chunk);
|
||||
});
|
||||
|
||||
it("rejects tampered chunks", async () => {
|
||||
const { dataKey } = await generateDataKey();
|
||||
const encrypted = new Uint8Array(await encryptChunk(new Uint8Array(32).buffer, dataKey));
|
||||
encrypted[20]! ^= 1;
|
||||
|
||||
await expect(decryptChunk(encrypted.buffer, dataKey)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { encryptChunk, generateDataKey } from "#lib/shared/crypto/index.ts";
|
||||
import { concatenateBuffers } from "#lib/shared/crypto/index.ts";
|
||||
import {
|
||||
CHUNK_SIZE,
|
||||
createDecryptionStream,
|
||||
getEncryptedRange,
|
||||
getEncryptedSize,
|
||||
} from "#lib/shared/format/index.ts";
|
||||
|
||||
const plaintext = Uint8Array.from({ length: 2 * CHUNK_SIZE + 12345 }, (_, i) => (i * 7) % 251);
|
||||
|
||||
// `toEqual` is too slow for large arrays
|
||||
const expectBytes = (actual: Uint8Array, expected: Uint8Array) => {
|
||||
expect(actual.byteLength).toBe(expected.byteLength);
|
||||
expect(Buffer.from(actual).equals(Buffer.from(expected))).toBe(true);
|
||||
};
|
||||
|
||||
const encrypt = async (data: Uint8Array, dataKey: CryptoKey, chunkSize = CHUNK_SIZE) => {
|
||||
const chunks: ArrayBuffer[] = [];
|
||||
for (let offset = 0; offset < data.byteLength; offset += chunkSize) {
|
||||
chunks.push(await encryptChunk(data.slice(offset, offset + chunkSize).buffer, dataKey));
|
||||
}
|
||||
return concatenateBuffers(...chunks);
|
||||
};
|
||||
|
||||
// Feeds the input in pieces of irregular sizes like a network stream
|
||||
const toStream = (data: Uint8Array) =>
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
let offset = 0;
|
||||
for (let i = 0; offset < data.byteLength; i++) {
|
||||
const size = [1, 1000, 65536, 3_000_000][i % 4]!;
|
||||
controller.enqueue(data.slice(offset, offset + size));
|
||||
offset += size;
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const decrypt = async (
|
||||
encrypted: Uint8Array,
|
||||
dataKey: CryptoKey,
|
||||
start: number,
|
||||
end: number,
|
||||
isLegacy = false,
|
||||
) => {
|
||||
const encryptedRange = getEncryptedRange(start, end, encrypted.byteLength, isLegacy);
|
||||
const stream = toStream(
|
||||
encrypted.slice(encryptedRange.start, encryptedRange.end + 1),
|
||||
).pipeThrough(
|
||||
createDecryptionStream({
|
||||
range: { start, end, total: plaintext.byteLength },
|
||||
chunkCount: encryptedRange.lastChunkIndex - encryptedRange.firstChunkIndex + 1,
|
||||
dataKey,
|
||||
isLegacy,
|
||||
}),
|
||||
);
|
||||
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
};
|
||||
|
||||
describe("createDecryptionStream", async () => {
|
||||
const { dataKey } = await generateDataKey();
|
||||
const encrypted = await encrypt(plaintext, dataKey);
|
||||
|
||||
it("produces encrypted content of the expected size", () => {
|
||||
expect(encrypted.byteLength).toBe(getEncryptedSize(plaintext.byteLength));
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, plaintext.byteLength - 1],
|
||||
[0, 0],
|
||||
[10, 20],
|
||||
[CHUNK_SIZE - 5, CHUNK_SIZE + 5],
|
||||
[CHUNK_SIZE, 2 * CHUNK_SIZE - 1],
|
||||
[CHUNK_SIZE + 1, plaintext.byteLength - 1],
|
||||
[plaintext.byteLength - 1, plaintext.byteLength - 1],
|
||||
])("decrypts the range %i-%i", async (start, end) => {
|
||||
expectBytes(await decrypt(encrypted, dataKey, start, end), plaintext.slice(start, end + 1));
|
||||
});
|
||||
|
||||
it("decrypts ranges of legacy files", async () => {
|
||||
const legacyEncrypted = await encrypt(plaintext, dataKey, plaintext.byteLength);
|
||||
|
||||
expectBytes(
|
||||
await decrypt(legacyEncrypted, dataKey, 100, CHUNK_SIZE + 100, true),
|
||||
plaintext.slice(100, CHUNK_SIZE + 101),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects truncated content", async () => {
|
||||
const stream = toStream(encrypted.slice(0, CHUNK_SIZE)).pipeThrough(
|
||||
createDecryptionStream({
|
||||
range: { start: 0, end: plaintext.byteLength - 1, total: plaintext.byteLength },
|
||||
chunkCount: 3,
|
||||
dataKey,
|
||||
isLegacy: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(new Response(stream).arrayBuffer()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FilesystemCache } from "#lib/features/filesystem/cache.svelte.ts";
|
||||
|
||||
interface Entry {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const createCache = (options?: {
|
||||
local?: Record<number, string>;
|
||||
server?: Record<number, string>;
|
||||
}) => {
|
||||
const local = options?.local ?? {};
|
||||
const server = options?.server ?? {};
|
||||
const fetchFromServer = vi.fn(async (id: number): Promise<Entry> => {
|
||||
if (!(id in server)) throw new Error("Not found");
|
||||
return { id, name: server[id]! };
|
||||
});
|
||||
const cache = new FilesystemCache<number, Entry>({
|
||||
fetchFromIndexedDB: async (id) => (id in local ? { id, name: local[id]! } : undefined),
|
||||
fetchFromServer,
|
||||
bulkFetchFromIndexedDB: async (ids) =>
|
||||
new Map([...ids].filter((id) => id in local).map((id) => [id, { id, name: local[id]! }])),
|
||||
bulkFetchFromServer: async (ids) =>
|
||||
new Map([...ids.keys()].map((id) => [id, { id, name: server[id]! }])),
|
||||
});
|
||||
return { cache, server, fetchFromServer };
|
||||
};
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe("FilesystemCache", () => {
|
||||
it("revalidates again after invalidation during a pending request, coalescing repeated calls", async () => {
|
||||
const pending = Promise.withResolvers<Entry>();
|
||||
const fetchFromServer = vi
|
||||
.fn<(id: number) => Promise<Entry>>()
|
||||
.mockImplementationOnce(() => pending.promise)
|
||||
.mockResolvedValue({ id: 1, name: "new" });
|
||||
const cache = new FilesystemCache<number, Entry>({
|
||||
fetchFromIndexedDB: async () => undefined,
|
||||
fetchFromServer,
|
||||
});
|
||||
const loading = cache.get(1);
|
||||
await vi.waitFor(() => expect(fetchFromServer).toHaveBeenCalledTimes(1));
|
||||
|
||||
cache.invalidate(1);
|
||||
cache.invalidate(1);
|
||||
cache.invalidate(1);
|
||||
pending.resolve({ id: 1, name: "old" });
|
||||
const entry = await loading;
|
||||
await vi.waitFor(() => expect(entry.name).toBe("new"));
|
||||
expect(fetchFromServer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("revalidates only invalidated entries after a pending bulk request", async () => {
|
||||
const pending = Promise.withResolvers<Map<number, Entry>>();
|
||||
const fetchFromServer = vi.fn(async (id: number) => ({ id, name: "new" }));
|
||||
const bulkFetchFromServer = vi.fn(() => pending.promise);
|
||||
const cache = new FilesystemCache<number, Entry>({
|
||||
fetchFromIndexedDB: async () => undefined,
|
||||
fetchFromServer,
|
||||
bulkFetchFromIndexedDB: async () => new Map(),
|
||||
bulkFetchFromServer,
|
||||
});
|
||||
const loading = cache.bulkGet(new Set([1, 2]));
|
||||
await vi.waitFor(() => expect(bulkFetchFromServer).toHaveBeenCalledTimes(1));
|
||||
cache.invalidate(1);
|
||||
pending.resolve(new Map([1, 2].map((id) => [id, { id, name: "old" }])));
|
||||
|
||||
const entries = await loading;
|
||||
await vi.waitFor(() => expect(entries.get(1)?.name).toBe("new"));
|
||||
expect(entries.get(2)?.name).toBe("old");
|
||||
expect(fetchFromServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves invalidation when a pending request fails", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const pending = Promise.withResolvers<Entry>();
|
||||
const fetchFromServer = vi
|
||||
.fn<() => Promise<Entry>>()
|
||||
.mockImplementationOnce(() => pending.promise)
|
||||
.mockResolvedValue({ id: 1, name: "new" });
|
||||
const cache = new FilesystemCache<number, Entry>({
|
||||
fetchFromIndexedDB: async () => ({ id: 1, name: "local" }),
|
||||
fetchFromServer,
|
||||
});
|
||||
const entry = await cache.get(1);
|
||||
await vi.waitFor(() => expect(fetchFromServer).toHaveBeenCalledTimes(1));
|
||||
cache.invalidate(1);
|
||||
pending.reject(new Error("Failed"));
|
||||
|
||||
await vi.waitFor(() => expect(entry.name).toBe("new"));
|
||||
expect(fetchFromServer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("doesn't revalidate evicted entries after a pending bulk request", async () => {
|
||||
const pending = Promise.withResolvers<Map<number, Entry>>();
|
||||
const fetchFromServer = vi.fn(async (id: number) => ({ id, name: "new" }));
|
||||
const bulkFetchFromServer = vi.fn(() => pending.promise);
|
||||
const cache = new FilesystemCache<number, Entry>({
|
||||
fetchFromIndexedDB: async () => undefined,
|
||||
fetchFromServer,
|
||||
bulkFetchFromIndexedDB: async () => new Map(),
|
||||
bulkFetchFromServer,
|
||||
});
|
||||
const loading = cache.bulkGet(new Set([1]));
|
||||
await vi.waitFor(() => expect(bulkFetchFromServer).toHaveBeenCalledTimes(1));
|
||||
cache.invalidate(1);
|
||||
cache.evict(1);
|
||||
const replacement = await cache.get(1);
|
||||
pending.resolve(new Map([[1, { id: 1, name: "old" }]]));
|
||||
await loading;
|
||||
await flush();
|
||||
|
||||
expect(replacement.name).toBe("new");
|
||||
expect(fetchFromServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves with the local value first and updates it in place", async () => {
|
||||
const { cache } = createCache({ local: { 1: "local" }, server: { 1: "server" } });
|
||||
|
||||
const entry = await cache.get(1);
|
||||
expect(entry.name).toBe("local");
|
||||
|
||||
await flush();
|
||||
expect(entry.name).toBe("server");
|
||||
});
|
||||
|
||||
it("returns the cached value synchronously", async () => {
|
||||
const { cache } = createCache({ server: { 1: "server" } });
|
||||
|
||||
const entry = await cache.get(1);
|
||||
await flush();
|
||||
|
||||
expect(cache.get(1)).toBe(entry);
|
||||
});
|
||||
|
||||
it("revalidates entries when invalidated", async () => {
|
||||
const { cache, server, fetchFromServer } = createCache({ server: { 1: "old" } });
|
||||
const entry = await cache.get(1);
|
||||
await flush();
|
||||
|
||||
server[1] = "new";
|
||||
cache.invalidate(1);
|
||||
await flush();
|
||||
|
||||
expect(fetchFromServer).toHaveBeenCalledTimes(2);
|
||||
expect(entry.name).toBe("new");
|
||||
});
|
||||
|
||||
it("ignores invalidation of entries that haven't been loaded", () => {
|
||||
const { cache, fetchFromServer } = createCache();
|
||||
|
||||
cache.invalidate(1);
|
||||
|
||||
expect(fetchFromServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects if loading fails without a cached value", async () => {
|
||||
const { cache } = createCache();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await expect(cache.get(1)).rejects.toThrow("Not found");
|
||||
// Can be retried
|
||||
await expect(cache.get(1)).rejects.toThrow("Not found");
|
||||
});
|
||||
|
||||
it("loads multiple entries at once", async () => {
|
||||
const { cache } = createCache({ local: { 1: "local" }, server: { 1: "a", 2: "b" } });
|
||||
|
||||
const entries = await cache.bulkGet(new Set([1, 2]));
|
||||
await flush();
|
||||
|
||||
expect(entries.get(1)?.name).toBe("a");
|
||||
expect(entries.get(2)?.name).toBe("b");
|
||||
expect(cache.get(2)).toBe(entries.get(2));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { keyring } from "#lib/client/keys/keyring.svelte.ts";
|
||||
import type { MasterKey } from "#lib/client/keys/types.ts";
|
||||
|
||||
vi.mock("#lib/client/storage/indexed-db/index.ts", () => ({
|
||||
getClientKey: vi.fn(async () => null),
|
||||
getMasterKeys: vi.fn(async () => []),
|
||||
getHmacSecrets: vi.fn(async () => []),
|
||||
storeClientKey: vi.fn(async () => {}),
|
||||
storeMasterKeys: vi.fn(async () => {}),
|
||||
storeHmacSecrets: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const key = {} as CryptoKey;
|
||||
const masterKey = (version: number, state: MasterKey["state"]): MasterKey => ({
|
||||
version,
|
||||
state,
|
||||
key,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await keyring.setMasterKeys([]);
|
||||
await keyring.setHmacSecrets([]);
|
||||
});
|
||||
|
||||
describe("Keyring", () => {
|
||||
it("uses the latest active master key for new data", async () => {
|
||||
await keyring.setMasterKeys([
|
||||
masterKey(1, "retired"),
|
||||
masterKey(3, "active"),
|
||||
masterKey(2, "active"),
|
||||
]);
|
||||
|
||||
expect(keyring.hasMasterKeys).toBe(true);
|
||||
expect(keyring.activeMasterKey.version).toBe(3);
|
||||
});
|
||||
|
||||
it("finds master keys by version", async () => {
|
||||
await keyring.setMasterKeys([masterKey(1, "retired"), masterKey(2, "active")]);
|
||||
|
||||
expect(keyring.getMasterKey(1).state).toBe("retired");
|
||||
expect(() => keyring.getMasterKey(3)).toThrow("not found");
|
||||
});
|
||||
|
||||
it("throws if there is no active key", async () => {
|
||||
await keyring.setMasterKeys([masterKey(1, "retired")]);
|
||||
|
||||
expect(() => keyring.activeMasterKey).toThrow("Active master key not found");
|
||||
expect(() => keyring.activeHmacSecret).toThrow("Active HMAC secret not found");
|
||||
});
|
||||
|
||||
it("requires client keys", () => {
|
||||
expect(() => keyring.requireClientKeys()).toThrow("Client keys not found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CHUNK_SIZE,
|
||||
ENCRYPTED_CHUNK_SIZE,
|
||||
ENCRYPTION_OVERHEAD,
|
||||
getDecryptedSize,
|
||||
getEncryptedRange,
|
||||
getEncryptedSize,
|
||||
} from "#lib/shared/format/index.ts";
|
||||
import { getContentRangeHeader, parseRangeHeader, resolveRange } from "#lib/shared/http.ts";
|
||||
|
||||
describe("parseRangeHeader", () => {
|
||||
it("parses byte ranges", () => {
|
||||
expect(parseRangeHeader(null)).toBeUndefined();
|
||||
expect(parseRangeHeader("bytes=0-")).toEqual({ start: 0, end: undefined });
|
||||
expect(parseRangeHeader("bytes=10-20")).toEqual({ start: 10, end: 20 });
|
||||
expect(parseRangeHeader("bytes=10-20, 30-40")).toEqual({ start: 10, end: 20 });
|
||||
expect(parseRangeHeader("bytes=-500")).toEqual({ suffixLength: 500 });
|
||||
});
|
||||
|
||||
it("ignores invalid ranges", () => {
|
||||
expect(parseRangeHeader("bytes=-")).toBeUndefined();
|
||||
expect(parseRangeHeader("items=0-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRange", () => {
|
||||
it("resolves the whole content without a range", () => {
|
||||
expect(resolveRange(undefined, 100)).toEqual({ start: 0, end: 99, total: 100 });
|
||||
});
|
||||
|
||||
it("clamps the end to the content size", () => {
|
||||
expect(resolveRange({ start: 10, end: 1000 }, 100)).toEqual({ start: 10, end: 99, total: 100 });
|
||||
expect(resolveRange({ start: 10 }, 100)).toEqual({ start: 10, end: 99, total: 100 });
|
||||
});
|
||||
|
||||
it("resolves suffix ranges", () => {
|
||||
expect(resolveRange({ suffixLength: 10 }, 100)).toEqual({ start: 90, end: 99, total: 100 });
|
||||
expect(resolveRange({ suffixLength: 1000 }, 100)).toEqual({ start: 0, end: 99, total: 100 });
|
||||
});
|
||||
|
||||
it("rejects unsatisfiable ranges", () => {
|
||||
expect(resolveRange({ start: 100 }, 100)).toBeNull();
|
||||
expect(resolveRange({ start: 20, end: 10 }, 100)).toBeNull();
|
||||
});
|
||||
|
||||
it("formats Content-Range", () => {
|
||||
expect(getContentRangeHeader({ start: 1, end: 2, total: 3 })).toEqual({
|
||||
"Content-Range": "bytes 1-2/3",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("encrypted chunk layout", () => {
|
||||
it.each([1, CHUNK_SIZE - 1, CHUNK_SIZE, CHUNK_SIZE + 1, 3 * CHUNK_SIZE + 12345])(
|
||||
"computes the decrypted size of %i bytes",
|
||||
(size) => {
|
||||
expect(getEncryptedSize(size)).toBe(
|
||||
size + Math.ceil(size / CHUNK_SIZE) * ENCRYPTION_OVERHEAD,
|
||||
);
|
||||
expect(getDecryptedSize(getEncryptedSize(size), false)).toBe(size);
|
||||
},
|
||||
);
|
||||
|
||||
it("computes the decrypted size of legacy files", () => {
|
||||
expect(getDecryptedSize(100 + ENCRYPTION_OVERHEAD, true)).toBe(100);
|
||||
});
|
||||
|
||||
it("maps a plaintext range to whole encrypted chunks", () => {
|
||||
const totalSize = 3 * CHUNK_SIZE + 100;
|
||||
const totalEncryptedSize = getEncryptedSize(totalSize);
|
||||
|
||||
expect(getEncryptedRange(0, 10, totalEncryptedSize, false)).toEqual({
|
||||
firstChunkIndex: 0,
|
||||
lastChunkIndex: 0,
|
||||
start: 0,
|
||||
end: ENCRYPTED_CHUNK_SIZE - 1,
|
||||
});
|
||||
expect(getEncryptedRange(CHUNK_SIZE - 1, CHUNK_SIZE, totalEncryptedSize, false)).toEqual({
|
||||
firstChunkIndex: 0,
|
||||
lastChunkIndex: 1,
|
||||
start: 0,
|
||||
end: 2 * ENCRYPTED_CHUNK_SIZE - 1,
|
||||
});
|
||||
expect(getEncryptedRange(totalSize - 1, totalSize - 1, totalEncryptedSize, false)).toEqual({
|
||||
firstChunkIndex: 3,
|
||||
lastChunkIndex: 3,
|
||||
start: 3 * ENCRYPTED_CHUNK_SIZE,
|
||||
end: totalEncryptedSize - 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps any range of legacy files to the whole file", () => {
|
||||
expect(getEncryptedRange(5, 10, 1000, true)).toEqual({
|
||||
firstChunkIndex: 0,
|
||||
lastChunkIndex: 0,
|
||||
start: 0,
|
||||
end: 999,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user