사소한 리팩토링

This commit is contained in:
static committed 2026-10-20 03:12:53 +09:00
1 parent d986493431
commit 48bf9c1165
62 files changed
+31 -39

No files matched your search

+56
View File
@@ -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);
});
});
+92
View File
@@ -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();
});
});
+45
View File
@@ -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();
});
});
+103
View File
@@ -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();
});
});
+178
View File
@@ -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));
});
});
+55
View File
@@ -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");
});
});
+101
View File
@@ -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,
});
});
});