mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 08:10:28 +09:00
223 lines
7.1 KiB
TypeScript
223 lines
7.1 KiB
TypeScript
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("#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("#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);
|
|
}
|
|
}
|