사소한 리팩토링

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

+59
View File
@@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test";
import { App } from "./support/app.ts";
let app: App;
test.describe.configure({ mode: "serial" });
test.beforeAll(async ({ browser }) => {
app = await App.create(browser);
});
test.afterAll(async () => {
await app.close();
});
test("로그인 후 새 암호 키를 생성하면 홈으로 이동한다", async () => {
await app.login();
await app.generateKeys();
await app.page.waitForURL(/\/home/);
});
test("비밀번호를 바꾸지 못하면 오류를 토스트로 표시한다", async () => {
const { page } = app;
await page.goto("/auth/change-password");
await app.field("기존 비밀번호").fill("wrong-password");
await app.field("새 비밀번호").fill("new-password-1234");
await app.field("새 비밀번호 확인").fill("new-password-1234");
await page.getByRole("button", { name: "비밀번호 바꾸기" }).click();
await expect(page.getByRole("alert")).toContainText("비밀번호를 바꾸지 못했어요");
await expect(page).toHaveURL(/\/auth\/change-password/);
});
test("저장된 키가 있으면 다시 로그인할 때 키를 생성하지 않는다", async () => {
await app.page.goto("/menu");
await app.text("로그아웃").click();
await app.page.waitForURL(/\/auth\/login/);
await app.login();
await app.page.waitForURL(/\/home/);
});
test("새 디바이스는 다른 디바이스의 승인을 기다린다", async ({ browser }) => {
const device = await app.addDevice(browser);
const { page } = device;
try {
await device.login();
await device.generateKeys();
await page.waitForURL(/\/client\/pending/);
await expect(page.getByText("암호 키 지문", { exact: true })).toBeVisible();
await expect(page.locator("body")).toContainText(/[0-9A-F]{4} [0-9A-F]{4}/);
await page.waitForTimeout(1000);
await expect(page).toHaveURL(/\/client\/pending/);
} finally {
await device.close();
}
});
+127
View File
@@ -0,0 +1,127 @@
import { expect, test } from "@playwright/test";
import { readFile, writeFile } from "fs/promises";
import { App, CHUNK_SIZE, sha256 } from "./support/app.ts";
import { createLargeAsset, createMediaAssets, type Asset } from "./support/assets.ts";
// Values of `HTMLMediaElement.readyState`, as DOM globals aren't available in tests
const HAVE_CURRENT_DATA = 2;
let app: App;
let image: Asset;
let video: Asset;
let large: Asset;
let fileIds: Map<string, number>;
const listUploadCaches = () =>
app.page.evaluate(async () => {
const root = await navigator.storage.getDirectory();
const cache = await root.getDirectoryHandle("cache", { create: true });
const upload = await cache.getDirectoryHandle("upload", { create: true });
const names: string[] = [];
for await (const name of upload.keys()) names.push(name);
return names;
});
test.describe.configure({ mode: "serial" });
test.beforeAll(async ({ browser }) => {
app = await App.create(browser);
await app.signUp();
({ image, video } = await createMediaAssets(app.page));
large = createLargeAsset();
});
test.afterAll(async () => {
await app.close();
});
test("업로드한 파일은 청크 단위로 캐시되고 임시 폴더는 남지 않는다", async () => {
await app.upload([image, video, large]);
fileIds = await app.getFileIds();
for (const asset of [image, video, large]) {
await app.expectCached(fileIds.get(asset.mimeType)!, asset);
}
expect(await listUploadCaches()).toEqual([]);
});
test("일부 구간을 요청하면 해당 청크만 캐시되고, 이후 캐시와 서버의 청크를 함께 사용한다", async () => {
const id = fileIds.get(large.mimeType)!;
// Opening the file lets the service worker know its key, and saves it
const download = app.page.waitForEvent("download");
await app.openFile(large.name);
await (await download).path();
await app.expectCached(id, large);
await app.clearCache(id);
const start = CHUNK_SIZE + 10;
const partial = await app.fetchDecrypted(id, `bytes=${start}-${start + 10}`);
expect(partial).toMatchObject({
status: 206,
hash: sha256(large.buffer.subarray(start, start + 11)),
});
await app.expectCached(id, large, [1]);
// Chunk 1 is read from the cache, and chunks 0 and 2 from the server
const full = await app.fetchDecrypted(id);
expect(full).toMatchObject({ status: 200, hash: sha256(large.buffer) });
await app.expectCached(id, large);
});
test("재생하면서 캐시된 동영상은 다음 재생 때 서버 대신 캐시에서 읽는다", async () => {
const id = fileIds.get(video.mimeType)!;
await app.clearCache(id);
await app.page.goto(`/file/${id}`);
const element = app.page.locator("video");
await expect
.poll(() => element.evaluate((video: HTMLVideoElement) => video.readyState))
.toBeGreaterThanOrEqual(HAVE_CURRENT_DATA);
await app.expectCached(id, video);
// Corrupt the content on the server, so that only the cache can be decrypted
const path = await app.getFilePath(id);
const original = await readFile(path);
await writeFile(path, Buffer.alloc(original.length));
try {
await app.page.goto("/directory");
await app.page.goto(`/file/${id}`);
await expect
.poll(() => element.evaluate((video: HTMLVideoElement) => video.readyState))
.toBeGreaterThanOrEqual(HAVE_CURRENT_DATA);
expect((await app.fetchDecrypted(id)).hash).toBe(sha256(video.buffer));
} finally {
await writeFile(path, original);
}
});
test("이전 형식의 캐시와 오래된 업로드 임시 폴더는 앱을 시작할 때 삭제된다", async () => {
const { page } = app;
await page.evaluate(async () => {
const root = await navigator.storage.getDirectory();
const cache = await root.getDirectoryHandle("cache", { create: true });
const writable = await (await cache.getFileHandle("999999", { create: true })).createWritable();
await writable.write("plaintext");
await writable.close();
// A stale one, and one which might be in progress in another tab
const upload = await cache.getDirectoryHandle("upload", { create: true });
await upload.getDirectoryHandle("1000-stale", { create: true });
await upload.getDirectoryHandle(`${Date.now()}-fresh`, { create: true });
});
await page.reload();
await expect
.poll(() =>
page.evaluate(async () => {
const root = await navigator.storage.getDirectory();
const cache = await root.getDirectoryHandle("cache");
return await cache.getFileHandle("999999").then(
() => true,
() => false,
);
}),
)
.toBe(false);
await expect.poll(listUploadCaches).toEqual([expect.stringMatching(/-fresh$/)]);
});
+54
View File
@@ -0,0 +1,54 @@
# Runs the app built from the Dockerfile and the E2E tests in isolation (see run.sh)
name: arkvault-e2e
services:
database:
image: postgres:17
environment:
POSTGRES_USER: arkvault
POSTGRES_PASSWORD: e2e
POSTGRES_DB: arkvault
tmpfs: /var/lib/postgresql/data
healthcheck:
test: pg_isready -U arkvault
interval: 1s
retries: 30
app:
build: ../..
user: ${E2E_UID:-0}:${E2E_GID:-0}
environment:
NODE_ENV: production
PORT: 4173
ORIGIN: http://127.0.0.1:4173
DATABASE_HOST: database
DATABASE_USER: arkvault
DATABASE_PASSWORD: e2e
DATABASE_NAME: arkvault
SESSION_SECRET: e2e
LIBRARY_PATH: /data/library
THUMBNAILS_PATH: /data/thumbnails
volumes:
- ${E2E_DATA_DIR:?}:/data
depends_on:
database:
condition: service_healthy
playwright:
# Must match the version of @playwright/test
image: mcr.microsoft.com/playwright:v1.64.0-noble
user: ${E2E_UID:-0}:${E2E_GID:-0}
# Shares the network with the app, so that it is served from a secure context (127.0.0.1)
# which service workers require
network_mode: service:app
working_dir: /work
environment:
HOME: /tmp
CI: ${CI:-}
volumes:
- ../..:/work
# Accessed by the tests to manipulate the stored files
- ${E2E_DATA_DIR:?}:/data
command: npx playwright test -c test/e2e/playwright.config.ts ${E2E_ARGS:-}
depends_on:
- app
+181
View File
@@ -0,0 +1,181 @@
import { expect, test } from "@playwright/test";
import { App } from "./support/app.ts";
import { createLargeAsset, createMediaAssets, type Asset } from "./support/assets.ts";
let app: App;
let image: Asset;
let video: Asset;
let large: Asset;
test.describe.configure({ mode: "serial" });
test.beforeAll(async ({ browser }) => {
app = await App.create(browser);
await app.signUp();
({ image, video } = await createMediaAssets(app.page));
large = createLargeAsset();
});
test.afterAll(async () => {
await app.close();
});
test("폴더를 만든다", async () => {
await app.page.goto("/directory");
await app.page.locator("button.pointer-events-auto").click(); // Floating button
await app.text("폴더 만들기").click();
await app.field("폴더 이름").fill("문서");
await app.text("만들기").click();
await expect(app.row("문서")).toBeVisible();
});
test("파일을 업로드하면 목록에 썸네일과 함께 표시된다", async () => {
await app.upload([image, video, large]);
await app.page.reload();
for (const { name } of [image, video, large]) {
await expect(app.row(name)).toBeVisible();
}
await expect(app.row(image.name).locator("img")).toBeVisible();
await expect(app.row(video.name).locator("img")).toBeVisible();
});
test("이미지를 서비스 워커가 복호화한 스트림으로 표시한다", async () => {
await app.openFile(image.name);
const img = app.page.locator(`img[alt="${image.name}"]`);
await expect(img).toHaveAttribute("src", /^\/_internal\/decrypted-file\/\d+$/);
await expect
.poll(() => img.evaluate((element: HTMLImageElement) => element.naturalWidth))
.toBe(640);
});
test("폴더 이름을 바꾼다", async () => {
await app.page.goto("/directory");
await app.openMenu("문서");
await app.text("이름 바꾸기").click();
await app.field("이름").fill("보관함");
await app.page.getByRole("button", { name: "바꾸기", exact: true }).click();
await expect(app.row("보관함")).toBeVisible();
});
test("즐겨찾기에 추가하고 즐겨찾기 화면에서 해제한다", async () => {
await app.page.goto("/directory");
await app.openMenu(image.name);
await app.text("즐겨찾기에 추가하기").click();
await expect(app.text("즐겨찾기에 추가하기")).toBeHidden(); // The menu closes on success
await app.page.goto("/favorites");
await expect(app.row(image.name)).toBeVisible();
await app.row(image.name).locator("[data-action-button]").click();
await expect(app.row(image.name)).toBeHidden();
await app.page.goto("/directory");
await app.openMenu(image.name);
await expect(app.text("즐겨찾기에 추가하기")).toBeVisible();
});
test("파일 화면의 메뉴에서 즐겨찾기를 전환한다", async () => {
await app.openFile(video.name);
const menuButton = app.page.locator("div.sticky button").last();
await menuButton.click();
await app.text("즐겨찾기").click();
await menuButton.click();
await expect(app.text("즐겨찾기 해제")).toBeVisible();
});
test("카테고리를 만들고 파일을 추가, 제거한 뒤 이름을 바꾸고 삭제한다", async () => {
const { page } = app;
await page.goto("/category");
await app.text("카테고리 추가하기").click();
await app.field("카테고리 이름").fill("여행");
await app.text("만들기").click();
await expect(app.row("여행")).toBeVisible();
await app.openFile(image.name);
await app.text("카테고리에 추가하기").click();
await app.row("여행").click();
await app.text("여행 카테고리에 추가하기").click();
await expect(app.row("여행")).toBeVisible();
await page.goto("/category");
await app.row("여행").click();
await page.waitForURL(/\/category\/\d+/);
await expect(app.row(image.name)).toBeVisible();
await app.row(image.name).locator("[data-action-button]").click();
await expect(app.row(image.name)).toBeHidden();
await page.goto("/category");
await app.openMenu("여행");
await app.text("이름 바꾸기").click();
await app.field("이름").fill("여행 기록");
await page.getByRole("button", { name: "바꾸기", exact: true }).click();
await expect(app.row("여행 기록")).toBeVisible();
await app.openMenu("여행 기록");
await app.text("삭제하기").click();
await app.text("삭제할게요").click();
await expect(app.row("여행 기록")).toBeHidden();
});
test("이름으로 검색한다", async () => {
await app.page.goto("/search");
await app.page.locator("input").first().fill("video");
await expect(app.row(video.name)).toBeVisible();
await expect(app.row(image.name)).toBeHidden();
});
test("갤러리에 사진과 동영상을 표시한다", async () => {
await app.page.goto("/gallery");
await expect(app.page.locator(`img[alt="${image.name}"]`)).toBeVisible();
await expect(app.page.locator(`img[alt="${video.name}"]`)).toBeVisible();
});
test("누락된 썸네일을 다시 생성한다", async () => {
const { rowCount } = await app.db.query(
`DELETE FROM thumbnail WHERE file_id IN (
SELECT f.id FROM file f JOIN "user" u ON f.user_id = u.id WHERE u.email = $1
)`,
[app.email],
);
expect(rowCount).toBe(2);
await app.page.goto("/settings/thumbnail");
await expect(app.row(image.name)).toBeVisible();
await expect(app.row(video.name)).toBeVisible();
await app.text("모두 썸네일 생성하기").click();
await expect(app.text("썸네일이 누락된 파일")).toBeHidden({ timeout: 60_000 });
const { rows } = await app.db.query(
`SELECT count(*)::int AS count FROM thumbnail t
JOIN file f ON t.file_id = f.id JOIN "user" u ON f.user_id = u.id WHERE u.email = $1`,
[app.email],
);
expect(rows[0].count).toBe(2);
});
test("파일을 삭제한다", async () => {
await app.page.goto("/directory");
await app.openMenu(large.name);
await app.text("삭제하기").click();
await app.text("삭제할게요").click();
await expect(app.row(large.name)).toBeHidden();
await app.page.reload();
await expect(app.row(image.name)).toBeVisible();
await expect(app.row(large.name)).toBeHidden();
});
test("폴더를 삭제한다", async () => {
await app.page.goto("/directory");
await app.openMenu("보관함");
await app.text("삭제하기").click();
await app.text("삭제할게요").click();
await expect(app.row("보관함")).toBeHidden();
});
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "@playwright/test";
import { BASE_URL } from "./support/app.ts";
// Run with `pnpm test:e2e`, which runs the app and the tests in Docker (see run.sh)
export default defineConfig({
testDir: ".",
outputDir: "results",
globalSetup: "./support/global-setup.ts",
timeout: 180_000,
expect: { timeout: 30_000 },
// Each test file signs up a new user, so files can run in parallel
workers: 2,
reporter: [["list"]],
use: {
baseURL: BASE_URL,
trace: "retain-on-failure",
},
});
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Runs the E2E tests in Docker. Arguments are passed to Playwright (e.g. `-g "cache"`).
# The containers are isolated from the development database and removed afterwards.
set -u
cd "$(dirname "$0")"
export E2E_UID="$(id -u)" E2E_GID="$(id -g)" E2E_ARGS="$*"
E2E_DATA_DIR="$(mktemp -d)"
export E2E_DATA_DIR
compose=(docker compose -f docker-compose.yaml)
"${compose[@]}" up --build --attach playwright --abort-on-container-exit --exit-code-from playwright
code=$?
"${compose[@]}" down --volumes --remove-orphans
rm -rf "$E2E_DATA_DIR"
exit $code
+106
View File
@@ -0,0 +1,106 @@
import { expect, test } from "@playwright/test";
import { readFile } from "fs/promises";
import { App, sha256 } from "./support/app.ts";
import { createLargeAsset, createMediaAssets, type Asset } from "./support/assets.ts";
// Values of `HTMLMediaElement.readyState`, as DOM globals aren't available in tests
const HAVE_METADATA = 1;
let app: App;
let video: Asset;
let large: Asset;
let fileIds: Map<string, number>;
test.describe.configure({ mode: "serial" });
test.beforeAll(async ({ browser }) => {
app = await App.create(browser);
await app.signUp();
({ video } = await createMediaAssets(app.page));
large = createLargeAsset();
await app.upload([video, large]);
fileIds = await app.getFileIds();
});
test.afterAll(async () => {
await app.close();
});
test("동영상을 서비스 워커가 복호화한 스트림으로 재생한다", async () => {
await app.openFile(video.name);
const element = app.page.locator("video");
await expect(element).toHaveAttribute("src", /^\/_internal\/decrypted-file\/\d+$/);
await expect
.poll(() => element.evaluate((video: HTMLVideoElement) => video.readyState))
.toBeGreaterThanOrEqual(HAVE_METADATA);
});
test("서비스 워커가 재시작되어도 페이지에서 메타데이터를 다시 받아 복호화한다", async () => {
const id = fileIds.get(video.mimeType)!;
await app.clearCache(id); // Decrypt from the server
const cdp = await app.context.newCDPSession(app.page);
await cdp.send("ServiceWorker.enable");
await cdp.send("ServiceWorker.stopAllWorkers");
const partial = await app.fetchDecrypted(id, "bytes=0-99");
expect(partial).toMatchObject({ status: 206, size: 100 });
const full = await app.fetchDecrypted(id);
expect(full).toMatchObject({ status: 200, hash: sha256(video.buffer) });
});
test("서비스 워커가 접미사 범위와 만족할 수 없는 범위를 처리한다", async () => {
const id = fileIds.get(video.mimeType)!;
const suffix = await app.fetchDecrypted(id, "bytes=-10");
expect(suffix).toMatchObject({ status: 206, size: 10 });
expect(suffix.hash).toBe(sha256(video.buffer.subarray(-10)));
const invalid = await app.fetchDecrypted(id, "bytes=999999999-");
expect(invalid.status).toBe(416);
});
test("다운로드 API는 Range 요청에만 206으로 응답한다", async () => {
const id = fileIds.get(video.mimeType)!;
const result = await app.page.evaluate(async (id) => {
const full = await fetch(`/api/file/${id}/download`);
const partial = await fetch(`/api/file/${id}/download`, { headers: { Range: "bytes=0-9" } });
return {
full: [full.status, full.headers.get("Content-Range")],
partial: [partial.status, partial.headers.get("Content-Range")],
};
}, id);
expect(result.full).toEqual([200, null]);
expect(result.partial[0]).toBe(206);
expect(result.partial[1]).toMatch(/^bytes 0-9\//);
});
test("큰 파일을 저장하면 내용이 일치하고 진행률이 전체 크기까지 보고된다", async () => {
const { page } = app;
await app.clearCache(fileIds.get(large.mimeType)!); // Download from the server
await page.goto("/directory");
await page.evaluate(() => {
const progress: unknown[] = [];
Object.assign(window, { progress });
navigator.serviceWorker.addEventListener("message", ({ data }) => {
if (data.type === "download-progress") progress.push(data);
});
});
const download = page.waitForEvent("download", { timeout: 120_000 });
await app.row(large.name).click();
const content = await readFile(await (await download).path());
expect(sha256(content)).toBe(sha256(large.buffer));
await expect
.poll(() =>
page.evaluate(() =>
(window as unknown as { progress: { status: string }[] }).progress.find(
({ status }) => status === "completed",
),
),
)
.toMatchObject({ loaded: large.buffer.length, total: large.buffer.length });
});
+222
View File
@@ -0,0 +1,222 @@
import { expect, type Browser, type BrowserContext, type Page } from "@playwright/test";
import { createHash, randomUUID } from "crypto";
import pg from "pg";
import type { Asset } from "./assets.ts";
export const BASE_URL = "http://127.0.0.1:4173";
export const CHUNK_SIZE = 4 * 1024 * 1024;
export const PASSWORD = "password1234";
// Argon2 hash of `PASSWORD`, as there is no way to sign up from the app
const PASSWORD_HASH =
"$argon2id$v=19$m=65536,p=4,t=3$EoYOFfAXppmuwKnIlpIJHg$7WMPMiTlC+474Ch3Ov7z8if5gkjJpdl+e+YDrDXRk1Y";
export const sha256 = (buffer: Uint8Array) => createHash("sha256").update(buffer).digest("hex");
export const createDatabaseClient = async () => {
const db = new pg.Client({
host: "database",
user: "arkvault",
password: "e2e",
database: "arkvault",
});
await db.connect();
return db;
};
export const newContext = (browser: Browser) => {
return browser.newContext({
baseURL: BASE_URL,
acceptDownloads: true,
viewport: { width: 420, height: 900 },
});
};
// Drives the app as a single user on a single device
export class App {
private constructor(
readonly context: BrowserContext,
readonly page: Page,
readonly db: pg.Client,
readonly email: string,
private readonly ownsDatabase = true,
) {}
static async create(browser: Browser) {
const db = await createDatabaseClient();
const email = `e2e-${randomUUID()}@example.com`;
await db.query(`INSERT INTO "user" (email, nickname, password) VALUES ($1, 'e2e', $2)`, [
email,
PASSWORD_HASH,
]);
const context = await newContext(browser);
return new App(context, await context.newPage(), db, email);
}
// Another device of the same user, which doesn't have the keys of this device
async addDevice(browser: Browser) {
const context = await newContext(browser);
return new App(context, await context.newPage(), this.db, this.email, false);
}
async close() {
await this.context.close();
if (this.ownsDatabase) {
await this.db.end();
}
}
// Locators
row(name: string) {
return this.page.locator("[data-entry-container]", { hasText: name }).first();
}
// Inputs are labeled by sibling elements instead of placeholders
field(label: string) {
return this.page
.locator("div.relative", {
has: this.page.locator("label", { hasText: new RegExp(`^\\s*${label}\\s*$`) }),
})
.locator("input")
.last();
}
text(text: string) {
return this.page.getByText(text, { exact: true }).first();
}
// Actions
async login() {
await this.page.goto("/home");
await this.page.waitForURL(/\/auth\/login/);
await this.field("이메일").fill(this.email);
await this.field("비밀번호").fill(PASSWORD);
await this.text("로그인").click();
}
async generateKeys() {
await this.page.waitForURL(/\/key\/generate/);
await this.text("새 암호 키 생성하기").click();
await this.page.waitForURL(/\/key\/export/);
await this.text("내보내지 않을래요").click();
await this.text("계속할게요").click();
}
async signUp() {
await this.login();
await this.generateKeys();
await this.page.waitForURL(/\/home/);
}
async openMenu(name: string) {
await this.row(name).locator("[data-action-button]").click();
}
async upload(assets: Asset[]) {
await this.page.goto("/directory");
await this.page.locator("input[type=file]").setInputFiles(assets);
for (const { name } of assets) {
await expect(this.row(name)).toBeVisible({ timeout: 60_000 });
}
// Wait until the uploads are finished
await expect(this.page.locator("body")).not.toContainText(
/대기 중|준비 중|암호화하는 중|업로드를 기다리는 중|전송됨/,
{ timeout: 120_000 },
);
}
async openFile(name: string) {
await this.page.goto("/directory");
await this.row(name).click();
await this.page.waitForURL(/\/file\/\d+/);
return Number(new URL(this.page.url()).pathname.split("/").pop());
}
// Database
async getFileIds() {
const { rows } = await this.db.query<{ id: number; content_type: string }>(
`SELECT f.id, f.content_type FROM file f JOIN "user" u ON f.user_id = u.id WHERE u.email = $1`,
[this.email],
);
return new Map(rows.map(({ id, content_type }) => [content_type, id]));
}
async getFilePath(fileId: number) {
const { rows } = await this.db.query<{ path: string }>("SELECT path FROM file WHERE id = $1", [
fileId,
]);
return rows[0]!.path;
}
// Service worker and cache
async fetchDecrypted(fileId: number, range?: string) {
return await this.page.evaluate(
async ({ fileId, range }) => {
const res = await fetch(`/_internal/decrypted-file/${fileId}`, {
headers: range ? { Range: range } : {},
});
const buffer = await res.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", buffer);
return {
status: res.status,
size: buffer.byteLength,
hash: Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""),
contentRange: res.headers.get("Content-Range"),
};
},
{ fileId, range },
);
}
// Returns the hashes of the cached chunks of the file
async readCache(fileId: number) {
return await this.page.evaluate(async (fileId) => {
const chunks: Record<string, string> = {};
try {
const root = await navigator.storage.getDirectory();
const cache = await root.getDirectoryHandle("cache");
const files = await cache.getDirectoryHandle("file");
const directory = await files.getDirectoryHandle(String(fileId));
for await (const handle of directory.values()) {
if (handle.kind === "file" && /^\d+$/.test(handle.name)) {
const buffer = await (await (handle as FileSystemFileHandle).getFile()).arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", buffer);
chunks[handle.name] = Array.from(new Uint8Array(digest), (b) =>
b.toString(16).padStart(2, "0"),
).join("");
}
}
} catch {
// Not cached
}
return chunks;
}, fileId);
}
async clearCache(fileId: number) {
await this.page.evaluate(async (fileId) => {
const root = await navigator.storage.getDirectory();
const cache = await root.getDirectoryHandle("cache", { create: true });
const files = await cache.getDirectoryHandle("file", { create: true });
await files.removeEntry(String(fileId), { recursive: true }).catch(() => {});
}, fileId);
}
// Waits until the chunks (all by default) are cached with the content of the asset
async expectCached(fileId: number, asset: Asset, chunkIndexes?: number[]) {
const indexes = chunkIndexes ?? [...Array(Math.ceil(asset.buffer.length / CHUNK_SIZE)).keys()];
const expected = Object.fromEntries(
indexes.map((index) => [
String(index),
sha256(asset.buffer.subarray(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE)),
]),
);
await expect.poll(() => this.readCache(fileId)).toEqual(expected);
}
}
+55
View File
@@ -0,0 +1,55 @@
import type { Page } from "@playwright/test";
import { randomBytes } from "crypto";
export interface Asset {
name: string;
mimeType: string;
buffer: Buffer;
}
// Generates a 640x480 PNG image and a short WebM video in the browser
export const createMediaAssets = async (page: Page) => {
const { png, webm } = await page.evaluate(async () => {
const toBase64 = async (blob: Blob) => new Uint8Array(await blob.arrayBuffer()).toBase64();
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 480;
const context = canvas.getContext("2d")!;
const draw = (frame: number) => {
context.fillStyle = `hsl(${(frame * 50) % 360}, 80%, 50%)`;
context.fillRect(0, 0, 640, 480);
context.fillStyle = "white";
context.font = "64px sans-serif";
context.fillText(`ArkVault ${frame}`, 40, 240);
};
draw(0);
const png = await new Promise<Blob>((resolve) => canvas.toBlob((blob) => resolve(blob!)));
const recorder = new MediaRecorder(canvas.captureStream(30), { mimeType: "video/webm" });
const chunks: Blob[] = [];
recorder.ondataavailable = ({ data }) => chunks.push(data);
recorder.start();
for (let frame = 0; frame < 30; frame++) {
draw(frame);
await new Promise((resolve) => setTimeout(resolve, 50));
}
recorder.stop();
await new Promise((resolve) => (recorder.onstop = resolve));
return { png: await toBase64(png), webm: await toBase64(new Blob(chunks)) };
});
return {
image: { name: "image.png", mimeType: "image/png", buffer: Buffer.from(png, "base64") },
video: { name: "video.webm", mimeType: "video/webm", buffer: Buffer.from(webm, "base64") },
} satisfies Record<string, Asset>;
};
// Spans three chunks, the last of which is partial
export const createLargeAsset = (): Asset => ({
name: "large.bin",
mimeType: "application/octet-stream",
buffer: randomBytes(9 * 1024 * 1024 + 123),
});
+15
View File
@@ -0,0 +1,15 @@
import { BASE_URL } from "./app.ts";
// Waits until the app is ready (e.g. migrations are done)
export default async function globalSetup() {
for (let i = 0; i < 120; i++) {
try {
const res = await fetch(`${BASE_URL}/auth/login`);
if (res.ok) return;
} catch {
// Not ready yet
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error("The app didn't start");
}
+114
View File
@@ -0,0 +1,114 @@
import { sql } from "kysely";
import db from "#lib/server/db/kysely.ts";
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(),
});
export const createFile = async (userId: number, parentId: DirectoryId, path: string) => {
const n = nextSequence();
const { id } = await db
.insertInto("file")
.values({
parent_id: parentId === "root" ? null : parentId,
user_id: userId,
path,
master_encryption_key_version: 1,
encrypted_data_encryption_key: `dek${n}`,
data_encryption_key_version: new Date(),
hmac_secret_key_version: null,
content_hmac: null,
content_type: "image/png",
encrypted_content_iv: null,
encrypted_content_hash: "hash",
encrypted_name: ciphertext(),
encrypted_created_at: null,
encrypted_last_modified_at: ciphertext(),
})
.returning("id")
.executeTakeFirstOrThrow();
return id;
};
export const getQueuedDeletions = async () => {
const rows = await db.selectFrom("file_deletion_queue").select("path").execute();
return rows.map(({ path }) => path).sort();
};
+12
View File
@@ -0,0 +1,12 @@
import { mkdir, writeFile } from "fs/promises";
import { dirname, join } from "path";
import env from "#lib/server/config.ts";
let sequence = 0;
export const createStoredFile = async (content: string | Buffer = "content") => {
const path = join(env.libraryPath, "test", `file${++sequence}`);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, content);
return path;
};
+92
View File
@@ -0,0 +1,92 @@
import { access } from "fs/promises";
import { beforeEach, describe, expect, it } from "vitest";
import { UploadRepo, db } from "#lib/server/db/index.ts";
import { processPendingFileDeletions } from "#lib/server/services/file-deletion.ts";
import { DirectoryService, FileService } from "#lib/server/services/index.ts";
import {
ciphertext,
createDirectory,
createFile,
createUser,
getQueuedDeletions,
newEntryParams,
resetDatabase,
} from "../helpers/database.ts";
import { createStoredFile } from "../helpers/storage.ts";
const exists = (path: string) =>
access(path).then(
() => true,
() => false,
);
beforeEach(resetDatabase);
describe("file deletion", () => {
it("deletes the stored file after the database row is deleted", async () => {
const { userId } = await createUser();
const path = await createStoredFile();
const fileId = await createFile(userId, "root", path);
await FileService.deleteFile(userId, fileId);
await processPendingFileDeletions();
expect(await exists(path)).toBe(false);
expect(await getQueuedDeletions()).toEqual([]);
});
it("keeps failed deletions in the queue", async () => {
const { userId } = await createUser();
const fileId = await createFile(userId, "root", "/proc/version"); // Can't be deleted
await FileService.deleteFile(userId, fileId);
await processPendingFileDeletions();
expect(await getQueuedDeletions()).toEqual(["/proc/version"]);
});
it("deletes directories recursively including pending uploads", async () => {
const { userId } = await createUser();
const rootId = await createDirectory(userId);
const childId = await createDirectory(userId, rootId);
const grandchildId = await createDirectory(userId, childId);
const otherId = await createDirectory(userId);
const paths = await Promise.all([1, 2, 3, 4].map(() => createStoredFile()));
const fileIds = [
await createFile(userId, rootId, paths[0]!),
await createFile(userId, grandchildId, paths[1]!),
];
const otherFileId = await createFile(userId, otherId, paths[2]!);
await UploadRepo.createFileUploadSession(db, {
...newEntryParams(userId, childId),
id: crypto.randomUUID(),
path: paths[3]!,
totalChunks: 1,
expiresAt: new Date(Date.now() + 60_000),
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
});
const { deletedFileIds } = await DirectoryService.deleteDirectory(userId, rootId);
expect(deletedFileIds.sort()).toEqual(fileIds.sort());
expect(await getQueuedDeletions()).toEqual([paths[0], paths[1], paths[3]].sort());
await expect(DirectoryService.getDirectory(userId, grandchildId)).rejects.toMatchObject({
code: "NOT_FOUND",
});
expect((await FileService.getFile(userId, otherFileId)).id).toBe(otherFileId);
});
it("rejects deleting another user's directory", async () => {
const { userId: ownerId } = await createUser();
const { userId: otherId } = await createUser();
const directoryId = await createDirectory(ownerId);
await expect(DirectoryService.deleteDirectory(otherId, directoryId)).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
});
+71
View File
@@ -0,0 +1,71 @@
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/storage/index.ts";
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("serves suffix ranges", async () => {
const path = await createFile("0123456789");
const { encContentStream, range } = await createEncContentStream(path, undefined, {
suffixLength: 3,
});
expect(range).toEqual({ start: 7, end: 9, total: 10 });
expect(await read(encContentStream)).toBe("789");
});
it("rejects unsatisfiable ranges", async () => {
const path = await createFile("0123456789");
await expect(createEncContentStream(path, undefined, { start: 10 })).rejects.toMatchObject({
code: "RANGE_NOT_SATISFIABLE",
});
});
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");
});
});
+61
View File
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it } from "vitest";
import { CategoryService, DirectoryService, UploadService } from "#lib/server/services/index.ts";
import {
createCategory,
createDirectory,
createUser,
newEntryParams,
resetDatabase,
ciphertext,
} from "../helpers/database.ts";
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 DirectoryService.createDirectory(newEntryParams(userId, parentId));
const { subDirectories } = await DirectoryService.getDirectory(userId, parentId);
expect(subDirectories).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(
DirectoryService.createDirectory(newEntryParams(attackerId, victimDirectoryId)),
).rejects.toMatchObject({ code: "NOT_FOUND", message: "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(
CategoryService.createCategory(newEntryParams(attackerId, victimCategoryId)),
).rejects.toMatchObject({ code: "NOT_FOUND", message: "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(
UploadService.startFileUpload({
...newEntryParams(attackerId, victimDirectoryId),
totalChunks: 1,
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
}),
).rejects.toMatchObject({ code: "NOT_FOUND", message: "Directory not found" });
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { RequestEvent } from "@sveltejs/kit";
import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it } from "vitest";
import { appRouter } from "#trpc/router.server.ts";
import { createDirectory, createUser, resetDatabase } from "../helpers/database.ts";
const createCaller = (userId: number, clientId?: number) =>
appRouter.createCaller({
locals: { ip: "127.0.0.1", userAgent: "test", session: { id: "session", userId, clientId } },
} as RequestEvent);
beforeEach(resetDatabase);
describe("tRPC error mapping", () => {
it("maps integrity errors to tRPC errors", async () => {
const { userId: ownerId } = await createUser();
const { userId, clientId } = await createUser();
const directoryId = await createDirectory(ownerId);
const error = await createCaller(userId, clientId)
.directory.create({
parent: directoryId,
mekVersion: 1,
dek: "ZGVr",
dekVersion: new Date(),
name: "bmFtZQ==",
nameIv: "aXY=",
})
.catch((e) => e);
expect(error).toBeInstanceOf(TRPCError);
expect(error).toMatchObject({ code: "NOT_FOUND", message: "Directory not found" });
});
it("maps service errors to tRPC errors", async () => {
const { userId, clientId } = await createUser();
await expect(
createCaller(userId, clientId).directory.create({
parent: "root",
mekVersion: 1,
dek: "ZGVr",
dekVersion: new Date(Date.now() - 3600 * 1000),
name: "bmFtZQ==",
nameIv: "aXY=",
}),
).rejects.toMatchObject({ code: "BAD_REQUEST", message: "Invalid DEK version" });
});
it("rejects clients that are not active", async () => {
const { userId } = await createUser();
await expect(createCaller(userId).file.list()).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
+157
View File
@@ -0,0 +1,157 @@
import { createHash } from "crypto";
import { Readable } from "stream";
import { beforeEach, describe, expect, it } from "vitest";
import { UploadRepo, db } from "#lib/server/db/index.ts";
import { FileService, UploadService } from "#lib/server/services/index.ts";
import { isChunkUploaded } from "#lib/server/services/upload.ts";
import { hashFile } from "#lib/server/storage/index.ts";
import { ciphertext, createUser, newEntryParams, resetDatabase } from "../helpers/database.ts";
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(db, {
...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(db, id, chunkIndex);
}
const session = await UploadRepo.getUploadSession(db, id, userId);
expect(session?.uploadedChunks).toBe(uploaded.length);
for (let chunkIndex = 1; chunkIndex <= 20; chunkIndex++) {
expect(isChunkUploaded(session!.bitmap, chunkIndex)).toBe(uploaded.includes(chunkIndex));
}
});
});
describe("upload completion", () => {
it.each([false, true])(
"rejects a late duplicate without changing the file (completed: %s)",
async (completeBeforeDuplicate) => {
const { userId } = await createUser();
const { uploadId } = await UploadService.startFileUpload({
...newEntryParams(userId, "root"),
totalChunks: 1,
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
});
const session = (await UploadRepo.getUploadSession(db, uploadId, userId))!;
const started = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
const duplicate = UploadService.uploadChunk(
userId,
uploadId,
1,
Readable.from(
(async function* () {
started.resolve();
await release.promise;
yield Buffer.alloc(100, 2);
})(),
),
);
await started.promise;
try {
const chunk = Buffer.alloc(100, 1);
const hash = createHash("sha256").update(chunk).digest("base64");
await UploadService.uploadChunk(userId, uploadId, 1, Readable.from([chunk]));
if (completeBeforeDuplicate) {
await UploadService.completeFileUpload(userId, uploadId, null, hash);
}
const rejected = expect(duplicate).rejects.toMatchObject({
code: completeBeforeDuplicate ? "NOT_FOUND" : "CONFLICT",
});
release.resolve();
await rejected;
expect(await hashFile(session.path)).toBe(hash);
if (!completeBeforeDuplicate) {
await UploadService.completeFileUpload(userId, uploadId, null, hash);
}
expect(await FileService.getAllFileIds(userId)).toHaveLength(1);
} finally {
release.resolve();
await duplicate.catch(() => {});
}
},
);
it("registers the file only once when completed concurrently", async () => {
const { userId } = await createUser();
const { uploadId } = await UploadService.startFileUpload({
...newEntryParams(userId, "root"),
totalChunks: 1,
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
});
const chunk = Buffer.alloc(100, 1);
await UploadService.uploadChunk(userId, uploadId, 1, Readable.from([chunk]));
const hash = createHash("sha256").update(chunk).digest("base64");
const results = await Promise.allSettled([
UploadService.completeFileUpload(userId, uploadId, null, hash),
UploadService.completeFileUpload(userId, uploadId, null, hash),
]);
expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1);
expect(results.find(({ status }) => status === "rejected")).toMatchObject({
reason: { code: "NOT_FOUND" },
});
expect(await FileService.getAllFileIds(userId)).toHaveLength(1);
});
it("discards corrupted uploads", async () => {
const { userId } = await createUser();
const { uploadId } = await UploadService.startFileUpload({
...newEntryParams(userId, "root"),
totalChunks: 1,
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
});
await UploadService.uploadChunk(userId, uploadId, 1, Readable.from([Buffer.alloc(100)]));
await expect(
UploadService.completeFileUpload(userId, uploadId, null, "invalid"),
).rejects.toMatchObject({ code: "CONFLICT" });
expect(await UploadRepo.getUploadSession(db, uploadId, userId)).toBeNull();
});
it("rejects chunks of invalid sizes", async () => {
const { userId } = await createUser();
const { uploadId } = await UploadService.startFileUpload({
...newEntryParams(userId, "root"),
totalChunks: 2,
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
});
await expect(
UploadService.uploadChunk(userId, uploadId, 1, Readable.from([Buffer.alloc(100)])),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
});
+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/index.ts";
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/config.ts", 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"),
},
};
});
+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,
});
});
});