mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 08:10:28 +09:00
상위 디렉터리 및 카테고리의 소유권을 검증하지 않던 문제 등 버그 수정 및 Vitest 기반 테스트 추가
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
67989f4741
commit
ed732be999
31 files changed
+2057
-42
No files matched your search
+5
-1
@@ -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",
|
||||
|
||||
Generated
+1320
-23
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,7 @@
|
||||
allowBuilds:
|
||||
argon2: true
|
||||
cpu-features: false
|
||||
esbuild: true
|
||||
exifreader: true
|
||||
protobufjs: false
|
||||
ssh2: false
|
||||
@@ -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(),
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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!);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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"));
|
||||
};
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user