상위 디렉터리 및 카테고리의 소유권을 검증하지 않던 문제 등 버그 수정 및 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

+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,
});
});
});