mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 08:10:28 +09:00
사소한 리팩토링
This commit is contained in:
1 parent
d986493431
commit
48bf9c1165
62 files changed
+31
-39
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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$/)]);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
Executable
+17
@@ -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
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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/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"),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user