mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 08:10:28 +09:00
서버 코드를 Router, Service, Repository 계층으로 분리하고 오류 처리 및 파일 삭제 로직 개선
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ed732be999
commit
6a6aa1765b
72 files changed
+1928
-1509
No files matched your search
@@ -82,3 +82,33 @@ export const newEntryParams = (userId: number, parentId: number | "root") => ({
|
||||
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.js";
|
||||
|
||||
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 } from "#lib/server/db/index.js";
|
||||
import { DirectoryService, FileService } from "#lib/server/services/index.js";
|
||||
import { processPendingFileDeletions } from "#lib/server/services/fileDeletion.js";
|
||||
import {
|
||||
ciphertext,
|
||||
createDirectory,
|
||||
createFile,
|
||||
createUser,
|
||||
getQueuedDeletions,
|
||||
newEntryParams,
|
||||
resetDatabase,
|
||||
} from "../helpers/database";
|
||||
import { createStoredFile } from "../helpers/storage";
|
||||
|
||||
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({
|
||||
...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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { join } from "path";
|
||||
import { buffer } from "stream/consumers";
|
||||
import type { ReadableStream } from "stream/web";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createEncContentStream } from "#lib/server/services/file.js";
|
||||
import { createEncContentStream } from "#lib/server/storage/index.js";
|
||||
|
||||
const createFile = async (content: string) => {
|
||||
const path = join(await mkdtemp(join(tmpdir(), "arkvault-test-")), "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.js";
|
||||
import { createDirectory, createUser, resetDatabase } from "../helpers/database";
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createHash } from "crypto";
|
||||
import { Readable } from "stream";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { UploadRepo } from "#lib/server/db/index.js";
|
||||
import { FileService, UploadService } from "#lib/server/services/index.js";
|
||||
import { isChunkUploaded } from "#lib/server/services/upload.js";
|
||||
import { ciphertext, createUser, newEntryParams, resetDatabase } from "../helpers/database";
|
||||
|
||||
@@ -33,3 +36,66 @@ describe("upload session bitmap", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload completion", () => {
|
||||
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(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" });
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { vi } from "vitest";
|
||||
|
||||
const storagePath = await mkdtemp(join(tmpdir(), "arkvault-test-"));
|
||||
|
||||
vi.mock("#lib/server/loadenv.js", async () => {
|
||||
vi.mock("#lib/server/config.js", async () => {
|
||||
const { inject } = await import("vitest");
|
||||
return {
|
||||
default: {
|
||||
|
||||
Reference in new issue
Block a user