상위 디렉터리 및 카테고리의 소유권을 검증하지 않던 문제 등 버그 수정 및 Vitest 기반 테스트 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
staticandClaude Opus 5.5 committed 2026-10-11 23:45:56 +09:00
1 parent 67989f4741
commit ed732be999
31 files changed
+2057 -42

No files matched your search

+5 -1
View File
@@ -12,6 +12,8 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test": "vitest run",
"test:watch": "vitest",
"db:up": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d",
"db:down": "docker compose -f docker-compose.dev.yaml -p arkvault-dev down",
"db:migrate": "kysely migrate"
@@ -27,6 +29,7 @@
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/svelte-virtual": "^3.13.40",
"@testcontainers/postgresql": "^12.2.0",
"@trpc/client": "^11.19.0",
"@types/file-saver": "^2.0.7",
"@types/ms": "^2.1.0",
@@ -56,7 +59,8 @@
"typescript": "^6.0.3",
"typescript-eslint": "^8.71.1",
"unplugin-icons": "^24.0.0",
"vite": "^8.3.4"
"vite": "^8.3.4",
"vitest": "^5.0.3"
},
"dependencies": {
"@trpc/server": "^11.19.0",
+1320 -23
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -1,4 +1,7 @@
allowBuilds:
argon2: true
cpu-features: false
esbuild: true
exifreader: true
protobufjs: false
ssh2: false
+3
View File
@@ -6,6 +6,7 @@ import {
getHmacSecrets,
} from "#lib/indexedDB/index.js";
import { prepareFileCache } from "#lib/modules/file/index.js";
import { listenForMetadataRequests } from "#lib/serviceWorker/index.js";
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "#lib/stores/index.js";
const requestPersistentStorage = async () => {
@@ -44,6 +45,8 @@ const prepareHmacSecretStore = async () => {
};
export const init: ClientInit = async () => {
listenForMetadataRequests();
await Promise.all([
requestPersistentStorage(),
prepareFileCache(),
+2 -2
View File
@@ -10,11 +10,11 @@ export const decodeString = (data: ArrayBuffer) => {
};
export const encodeToBase64 = (data: ArrayBuffer | Uint8Array) => {
return btoa(String.fromCharCode(...(data instanceof ArrayBuffer ? new Uint8Array(data) : data)));
return (data instanceof ArrayBuffer ? new Uint8Array(data) : data).toBase64();
};
export const decodeFromBase64 = (data: string) => {
return Uint8Array.from(atob(data), (c) => c.charCodeAt(0)).buffer;
return Uint8Array.fromBase64(data).buffer;
};
export const concatenateBuffers = (...buffers: ArrayBuffer[]) => {
+22
View File
@@ -14,6 +14,26 @@ interface Category {
export type NewCategory = Omit<Category, "id">;
export const ensureCategoryExists = async (
trx: typeof db,
userId: number,
categoryId: CategoryId,
) => {
if (categoryId === "root") return;
const category = await trx
.selectFrom("category")
.select("id")
.where("id", "=", categoryId)
.where("user_id", "=", userId)
.limit(1)
.forShare()
.executeTakeFirst();
if (!category) {
throw new IntegrityError("Category not found");
}
};
export const registerCategory = async (params: NewCategory) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
@@ -28,6 +48,8 @@ export const registerCategory = async (params: NewCategory) => {
throw new IntegrityError("Inactive MEK version");
}
await ensureCategoryExists(trx, params.userId, params.parentId);
const { categoryId } = await trx
.insertInto("category")
.values({
+22
View File
@@ -25,6 +25,26 @@ const toDirectory = (row: Selectable<DirectoryTable>): Directory => ({
isFavorite: row.is_favorite,
});
export const ensureDirectoryExists = async (
trx: typeof db,
userId: number,
directoryId: DirectoryId,
) => {
if (directoryId === "root") return;
const directory = await trx
.selectFrom("directory")
.select("id")
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.limit(1)
.forShare()
.executeTakeFirst();
if (!directory) {
throw new IntegrityError("Directory not found");
}
};
export const registerDirectory = async (params: Omit<Directory, "id" | "isFavorite">) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
@@ -39,6 +59,8 @@ export const registerDirectory = async (params: Omit<Directory, "id" | "isFavori
throw new IntegrityError("Inactive MEK version");
}
await ensureDirectoryExists(trx, params.userId, params.parentId);
const { directoryId } = await trx
.insertInto("directory")
.values({
+3
View File
@@ -1,5 +1,6 @@
import { sql } from "kysely";
import { IntegrityError } from "./error";
import { ensureDirectoryExists } from "./directory";
import db from "./kysely";
import type { Ciphertext } from "./schema";
@@ -62,6 +63,8 @@ export const createFileUploadSession = async (
}
}
await ensureDirectoryExists(trx, params.userId, params.parentId);
await trx
.insertInto("upload_session")
.values({
+2 -1
View File
@@ -4,7 +4,7 @@ import { stat } from "fs/promises";
import { Readable } from "stream";
import { FileRepo, MediaRepo } from "#lib/server/db/index.js";
const createEncContentStream = async (
export const createEncContentStream = async (
path: string,
iv?: Buffer,
range?: { start?: number; end?: number },
@@ -36,6 +36,7 @@ const createEncContentStream = async (
),
),
range: { start, end, total: totalSize },
isRangeRequest: !!range,
};
};
+1 -1
View File
@@ -7,7 +7,7 @@ import { safeUnlink } from "#lib/server/modules/filesystem.js";
const chunkLocks = new Set<string>();
const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => {
export const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => {
chunkIndex -= 1;
const byte = bitmap[Math.floor(chunkIndex / 8)];
return !!byte && (byte & (1 << (chunkIndex % 8))) !== 0; // Postgres sucks
+22
View File
@@ -3,6 +3,25 @@ import type { FileMetadata, ServiceWorkerMessage, ServiceWorkerResponse } from "
const PREPARE_TIMEOUT_MS = 5000;
// The service worker may be terminated at any time, losing its in-memory metadata.
// Keep a copy here so that it can be re-requested on demand.
const preparedFiles = new Map<number, FileMetadata>();
let isListeningForMetadataRequests = false;
export const listenForMetadataRequests = () => {
if (isListeningForMetadataRequests) return;
isListeningForMetadataRequests = true;
navigator.serviceWorker.addEventListener(
"message",
(event: MessageEvent<ServiceWorkerResponse>) => {
if (event.data.type === "decryption-metadata-request") {
event.ports[0]?.postMessage(preparedFiles.get(event.data.fileId) ?? null);
}
},
);
};
const getServiceWorker = async () => {
const registration = await navigator.serviceWorker.ready;
const sw = registration.active;
@@ -13,6 +32,9 @@ const getServiceWorker = async () => {
};
export const prepareFileDecryption = async (id: number, metadata: FileMetadata) => {
preparedFiles.set(id, metadata);
listenForMetadataRequests();
const sw = await getServiceWorker();
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
+7 -1
View File
@@ -15,5 +15,11 @@ export interface DecryptionReadyMessage {
fileId: number;
}
// Sent with a MessagePort to which the client replies with `FileMetadata | null`
export interface DecryptionMetadataRequestMessage {
type: "decryption-metadata-request";
fileId: number;
}
export type ServiceWorkerMessage = DecryptionPrepareMessage;
export type ServiceWorkerResponse = DecryptionReadyMessage;
export type ServiceWorkerResponse = DecryptionReadyMessage | DecryptionMetadataRequestMessage;
@@ -17,19 +17,19 @@ const downloadHandler = async (locals: App.Locals, params: RouteParams, request:
const { id } = zodRes.data;
const getStream = params.thumbnail ? getFileThumbnailStream : getFileStream;
const { encContentStream, range } = await getStream(
const { encContentStream, range, isRangeRequest } = await getStream(
userId,
id,
parseRangeHeader(request.headers.get("Range")),
);
return {
stream: encContentStream,
status: range ? 206 : 200,
status: isRangeRequest ? 206 : 200,
headers: {
"Accept-Ranges": "bytes",
"Content-Length": String(range.end - range.start + 1),
"Content-Type": "application/octet-stream",
...getContentRangeHeader(range),
...(isRangeRequest ? getContentRangeHeader(range) : {}),
},
};
};
+4 -4
View File
@@ -1,8 +1,8 @@
import { DECRYPTED_FILE_URL_PREFIX, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "../constants";
import { decryptChunk, getEncryptedRange, getDecryptedSize } from "../modules/crypto";
import { parseRangeHeader, getContentRangeHeader } from "../modules/http";
import { getFileMetadata } from "../modules/metadata";
import { getFile } from "../modules/opfs";
import { fileMetadataStore } from "../stores";
import type { FileMetadata } from "../types";
const createResponse = (
@@ -131,15 +131,15 @@ const streamFromServer = async (
);
};
const decryptFileHandler = async (request: Request) => {
const decryptFileHandler = async (request: Request, clientId: string) => {
const url = new URL(request.url);
const fileId = parseInt(url.pathname.slice(DECRYPTED_FILE_URL_PREFIX.length), 10);
if (isNaN(fileId)) {
throw new Response("Invalid file id", { status: 400 });
return new Response("Invalid file id", { status: 400 });
}
const downloadFilename = url.searchParams.get("download") ?? undefined;
const metadata = fileMetadataStore.get(fileId);
const metadata = await getFileMetadata(fileId, clientId);
const range = parseRangeHeader(request.headers.get("Range"));
const cache = await getFile(`/cache/${fileId}`);
if (cache) {
+1 -1
View File
@@ -24,7 +24,7 @@ self.addEventListener("message", (event) => {
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith(DECRYPTED_FILE_URL_PREFIX)) {
event.respondWith(decryptFile(event.request));
event.respondWith(decryptFile(event.request, event.clientId));
}
});
+46
View File
@@ -0,0 +1,46 @@
import { self } from "$app/service-worker";
import { fileMetadataStore } from "../stores";
import type { FileMetadata, ServiceWorkerResponse } from "../types";
const METADATA_REQUEST_TIMEOUT_MS = 3000;
const requestFromClient = (client: Client, fileId: number) => {
return new Promise<FileMetadata | null>((resolve) => {
const channel = new MessageChannel();
const timeout = setTimeout(() => {
channel.port1.close();
resolve(null);
}, METADATA_REQUEST_TIMEOUT_MS);
channel.port1.onmessage = (event: MessageEvent<FileMetadata | null>) => {
clearTimeout(timeout);
channel.port1.close();
resolve(event.data);
};
client.postMessage(
{ type: "decryption-metadata-request", fileId } satisfies ServiceWorkerResponse,
[channel.port2],
);
});
};
export const getFileMetadata = async (fileId: number, clientId: string) => {
const cached = fileMetadataStore.get(fileId);
if (cached) return cached;
// The worker might have been restarted since the client prepared the decryption
const requestingClient = clientId ? await self.clients.get(clientId) : undefined;
const windowClients = await self.clients.matchAll({ type: "window" });
const candidates = [
...(requestingClient ? [requestingClient] : []),
...windowClients.filter(({ id }) => id !== requestingClient?.id),
];
for (const client of candidates) {
const metadata = await requestFromClient(client, fileId);
if (metadata) {
fileMetadataStore.set(fileId, metadata);
return metadata;
}
}
};
+23 -1
View File
@@ -1,3 +1,25 @@
import type { FileMetadata } from "./types";
export const fileMetadataStore = new Map<number, FileMetadata>();
const MAX_FILE_METADATA_ENTRIES = 32;
const fileMetadata = new Map<number, FileMetadata>();
export const fileMetadataStore = {
get(fileId: number) {
const metadata = fileMetadata.get(fileId);
if (metadata) {
// Move to the end to keep the most recently used entries
fileMetadata.delete(fileId);
fileMetadata.set(fileId, metadata);
}
return metadata;
},
set(fileId: number, metadata: FileMetadata) {
fileMetadata.delete(fileId);
fileMetadata.set(fileId, metadata);
while (fileMetadata.size > MAX_FILE_METADATA_ENTRIES) {
fileMetadata.delete(fileMetadata.keys().next().value!);
}
},
};
+6 -2
View File
@@ -91,8 +91,12 @@ const categoryRouter = router({
encName: { ciphertext: input.name, iv: input.nameIv },
});
} catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
if (e instanceof IntegrityError) {
if (e.message === "Inactive MEK version") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
} else if (e.message === "Category not found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid parent category id" });
}
}
throw e;
}
+6 -2
View File
@@ -89,8 +89,12 @@ const directoryRouter = router({
encName: { ciphertext: input.name, iv: input.nameIv },
});
} catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
if (e instanceof IntegrityError) {
if (e.message === "Inactive MEK version") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
} else if (e.message === "Directory not found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid parent directory id" });
}
}
throw e;
}
+2
View File
@@ -98,6 +98,8 @@ const uploadRouter = router({
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid MEK version" });
} else if (e.message === "Inactive HSK version") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid HSK version" });
} else if (e.message === "Directory not found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid parent directory id" });
}
}
throw e;
+84
View File
@@ -0,0 +1,84 @@
import { sql } from "kysely";
import db from "#lib/server/db/kysely.js";
export const resetDatabase = async () => {
const { rows } = await sql<{ tablename: string }>`
SELECT tablename FROM pg_tables
WHERE schemaname = 'public' AND tablename NOT LIKE 'kysely_%'
`.execute(db);
await sql`TRUNCATE ${sql.join(rows.map(({ tablename }) => sql.table(tablename)))} RESTART IDENTITY CASCADE`.execute(
db,
);
};
let sequence = 0;
const nextSequence = () => ++sequence;
export const createUser = async () => {
const n = nextSequence();
const { id: userId } = await db
.insertInto("user")
.values({ email: `user${n}@example.com`, nickname: `user${n}`, password: "password" })
.returning("id")
.executeTakeFirstOrThrow();
const { id: clientId } = await db
.insertInto("client")
.values({ encryption_public_key: `enc${n}`, signature_public_key: `sig${n}` })
.returning("id")
.executeTakeFirstOrThrow();
await db
.insertInto("user_client")
.values({ user_id: userId, client_id: clientId, state: "active" })
.execute();
await db
.insertInto("master_encryption_key")
.values({ user_id: userId, version: 1, state: "active" })
.execute();
return { userId, clientId };
};
export const ciphertext = (value = "ciphertext") => ({ ciphertext: value, iv: "iv" });
export const createDirectory = async (userId: number, parentId: DirectoryId = "root") => {
const n = nextSequence();
const { id } = await db
.insertInto("directory")
.values({
parent_id: parentId === "root" ? null : parentId,
user_id: userId,
master_encryption_key_version: 1,
encrypted_data_encryption_key: `dek${n}`,
data_encryption_key_version: new Date(),
encrypted_name: ciphertext(),
})
.returning("id")
.executeTakeFirstOrThrow();
return id;
};
export const createCategory = async (userId: number, parentId: CategoryId = "root") => {
const n = nextSequence();
const { id } = await db
.insertInto("category")
.values({
parent_id: parentId === "root" ? null : parentId,
user_id: userId,
master_encryption_key_version: 1,
encrypted_data_encryption_key: `dek${n}`,
data_encryption_key_version: new Date(),
encrypted_name: ciphertext(),
})
.returning("id")
.executeTakeFirstOrThrow();
return id;
};
export const newEntryParams = (userId: number, parentId: number | "root") => ({
userId,
parentId,
mekVersion: 1,
encDek: `dek${nextSequence()}`,
dekVersion: new Date(),
encName: ciphertext(),
});
+52
View File
@@ -0,0 +1,52 @@
import { mkdtemp, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { buffer } from "stream/consumers";
import type { ReadableStream } from "stream/web";
import { describe, expect, it } from "vitest";
import { createEncContentStream } from "#lib/server/services/file.js";
const createFile = async (content: string) => {
const path = join(await mkdtemp(join(tmpdir(), "arkvault-test-")), "file");
await writeFile(path, content);
return path;
};
const read = async (stream: unknown) => (await buffer(stream as ReadableStream)).toString();
describe("createEncContentStream", () => {
it("serves the whole content when no range is requested", async () => {
const path = await createFile("0123456789");
const { encContentStream, range, isRangeRequest } = await createEncContentStream(path);
expect(isRangeRequest).toBe(false);
expect(range).toEqual({ start: 0, end: 9, total: 10 });
expect(await read(encContentStream)).toBe("0123456789");
});
it("serves the requested range", async () => {
const path = await createFile("0123456789");
const { encContentStream, range, isRangeRequest } = await createEncContentStream(
path,
undefined,
{ start: 2, end: 5 },
);
expect(isRangeRequest).toBe(true);
expect(range).toEqual({ start: 2, end: 5, total: 10 });
expect(await read(encContentStream)).toBe("2345");
});
it("prepends the legacy IV to the content", async () => {
const path = await createFile("6789");
const { encContentStream } = await createEncContentStream(path, Buffer.from("012345"), {
start: 4,
end: 7,
});
expect(await read(encContentStream)).toBe("4567");
});
});
+64
View File
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it } from "vitest";
import { CategoryRepo, DirectoryRepo, UploadRepo, IntegrityError } from "#lib/server/db/index.js";
import {
createCategory,
createDirectory,
createUser,
newEntryParams,
resetDatabase,
ciphertext,
} from "../helpers/database";
beforeEach(resetDatabase);
describe("parent ownership", () => {
it("allows creating a directory under the user's own directory", async () => {
const { userId } = await createUser();
const parentId = await createDirectory(userId);
await DirectoryRepo.registerDirectory(newEntryParams(userId, parentId));
const directories = await DirectoryRepo.getAllDirectoriesByParent(userId, parentId);
expect(directories).toHaveLength(1);
});
it("rejects creating a directory under another user's directory", async () => {
const { userId: victimId } = await createUser();
const { userId: attackerId } = await createUser();
const victimDirectoryId = await createDirectory(victimId);
await expect(
DirectoryRepo.registerDirectory(newEntryParams(attackerId, victimDirectoryId)),
).rejects.toThrow(new IntegrityError("Directory not found"));
});
it("rejects creating a category under another user's category", async () => {
const { userId: victimId } = await createUser();
const { userId: attackerId } = await createUser();
const victimCategoryId = await createCategory(victimId);
await expect(
CategoryRepo.registerCategory(newEntryParams(attackerId, victimCategoryId)),
).rejects.toThrow(new IntegrityError("Category not found"));
});
it("rejects uploading a file into another user's directory", async () => {
const { userId: victimId } = await createUser();
const { userId: attackerId } = await createUser();
const victimDirectoryId = await createDirectory(victimId);
await expect(
UploadRepo.createFileUploadSession({
...newEntryParams(attackerId, victimDirectoryId),
id: crypto.randomUUID(),
path: "unused",
totalChunks: 1,
expiresAt: new Date(Date.now() + 60_000),
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
}),
).rejects.toThrow(new IntegrityError("Directory not found"));
});
});
+35
View File
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it } from "vitest";
import { UploadRepo } from "#lib/server/db/index.js";
import { isChunkUploaded } from "#lib/server/services/upload.js";
import { ciphertext, createUser, newEntryParams, resetDatabase } from "../helpers/database";
beforeEach(resetDatabase);
describe("upload session bitmap", () => {
it("matches the bit order of PostgreSQL's set_bit", async () => {
const { userId } = await createUser();
const id = crypto.randomUUID();
await UploadRepo.createFileUploadSession({
...newEntryParams(userId, "root"),
id,
path: "unused",
totalChunks: 20,
expiresAt: new Date(Date.now() + 60_000),
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
});
const uploaded = [1, 8, 9, 20];
for (const chunkIndex of uploaded) {
await UploadRepo.markChunkAsUploaded(id, chunkIndex);
}
const session = await UploadRepo.getUploadSession(id, userId);
expect(session?.uploadedChunks).toBe(uploaded.length);
for (let chunkIndex = 1; chunkIndex <= 20; chunkIndex++) {
expect(isChunkUploaded(session!.bitmap, chunkIndex)).toBe(uploaded.includes(chunkIndex));
}
});
});
+53
View File
@@ -0,0 +1,53 @@
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { Kysely, PostgresDialect } from "kysely";
import { Migrator } from "kysely/migration";
import pg from "pg";
import type { TestProject } from "vitest/node";
import migrations from "../../src/lib/server/db/migrations";
declare module "vitest" {
export interface ProvidedContext {
database: {
host: string;
port: number;
user: string;
password: string;
name: string;
};
}
}
let container: StartedPostgreSqlContainer | undefined;
export const setup = async (project: TestProject) => {
container = await new PostgreSqlContainer("postgres:17").start();
const database = {
host: container.getHost(),
port: container.getPort(),
user: container.getUsername(),
password: container.getPassword(),
name: container.getDatabase(),
};
const db = new Kysely({
dialect: new PostgresDialect({
pool: new pg.Pool({ ...database, database: database.name }),
}),
});
try {
const { error } = await new Migrator({
db,
provider: { getMigrations: async () => migrations },
}).migrateToLatest();
if (error) throw error;
} finally {
await db.destroy();
}
project.provide("database", database);
};
export const teardown = async () => {
await container?.stop();
};
+13
View File
@@ -0,0 +1,13 @@
// Node.js 22 doesn't support base64 conversion of Uint8Array yet (latest Chromium does)
if (!Uint8Array.prototype.toBase64) {
Uint8Array.prototype.toBase64 = function () {
return Buffer.from(this.buffer, this.byteOffset, this.byteLength).toString("base64");
};
}
if (!Uint8Array.fromBase64) {
Uint8Array.fromBase64 = (data: string) => {
return new Uint8Array(Buffer.from(data, "base64"));
};
}
+20
View File
@@ -0,0 +1,20 @@
import { mkdtemp } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { vi } from "vitest";
const storagePath = await mkdtemp(join(tmpdir(), "arkvault-test-"));
vi.mock("#lib/server/loadenv.js", async () => {
const { inject } = await import("vitest");
return {
default: {
nodeEnv: "test",
database: inject("database"),
session: { secret: "test-secret", exp: 14 * 24 * 3600 * 1000 },
challenge: { userClientExp: 5 * 60 * 1000, sessionUpgradeExp: 5 * 60 * 1000 },
libraryPath: join(storagePath, "library"),
thumbnailsPath: join(storagePath, "thumbnails"),
},
};
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { BoundedQueue, Scheduler } from "#lib/utils/concurrency/index.js";
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/modules/crypto/index.js";
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();
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE, ENCRYPTION_OVERHEAD } from "#lib/constants/index.js";
import { getContentRangeHeader, parseRangeHeader } from "#lib/modules/http.js";
import { getDecryptedSize, getEncryptedRange } from "../../src/service-worker/modules/crypto";
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 });
});
it("formats Content-Range", () => {
expect(getContentRangeHeader({ start: 1, end: 2, total: 3 })).toEqual({
"Content-Range": "bytes 1-2/3",
});
});
});
describe("encrypted chunk layout", () => {
const encryptedSizeOf = (size: number) =>
size + Math.ceil(size / CHUNK_SIZE) * ENCRYPTION_OVERHEAD;
it.each([1, CHUNK_SIZE - 1, CHUNK_SIZE, CHUNK_SIZE + 1, 3 * CHUNK_SIZE + 12345])(
"computes the decrypted size of %i bytes",
(size) => {
expect(getDecryptedSize(encryptedSizeOf(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 = encryptedSizeOf(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,
});
});
});
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
projects: [
{
extends: true,
test: {
name: "unit",
include: ["tests/unit/**/*.test.ts"],
setupFiles: ["tests/setup/polyfills.ts"],
},
},
{
extends: true,
test: {
name: "server",
include: ["tests/server/**/*.test.ts"],
globalSetup: ["tests/setup/database.ts"],
setupFiles: ["tests/setup/server.ts"],
// Test files share a single database
fileParallelism: false,
hookTimeout: 60_000,
},
},
],
},
});