사소한 리팩토링

This commit is contained in:
static committed 2026-10-20 03:12:53 +09:00
1 parent d986493431
commit 48bf9c1165
62 files changed
+31 -39

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
import { access } from "fs/promises";
import { beforeEach, describe, expect, it } from "vitest";
import { UploadRepo, db } from "#lib/server/db/index.ts";
import { processPendingFileDeletions } from "#lib/server/services/file-deletion.ts";
import { DirectoryService, FileService } from "#lib/server/services/index.ts";
import {
ciphertext,
createDirectory,
createFile,
createUser,
getQueuedDeletions,
newEntryParams,
resetDatabase,
} from "../helpers/database.ts";
import { createStoredFile } from "../helpers/storage.ts";
const exists = (path: string) =>
access(path).then(
() => true,
() => false,
);
beforeEach(resetDatabase);
describe("file deletion", () => {
it("deletes the stored file after the database row is deleted", async () => {
const { userId } = await createUser();
const path = await createStoredFile();
const fileId = await createFile(userId, "root", path);
await FileService.deleteFile(userId, fileId);
await processPendingFileDeletions();
expect(await exists(path)).toBe(false);
expect(await getQueuedDeletions()).toEqual([]);
});
it("keeps failed deletions in the queue", async () => {
const { userId } = await createUser();
const fileId = await createFile(userId, "root", "/proc/version"); // Can't be deleted
await FileService.deleteFile(userId, fileId);
await processPendingFileDeletions();
expect(await getQueuedDeletions()).toEqual(["/proc/version"]);
});
it("deletes directories recursively including pending uploads", async () => {
const { userId } = await createUser();
const rootId = await createDirectory(userId);
const childId = await createDirectory(userId, rootId);
const grandchildId = await createDirectory(userId, childId);
const otherId = await createDirectory(userId);
const paths = await Promise.all([1, 2, 3, 4].map(() => createStoredFile()));
const fileIds = [
await createFile(userId, rootId, paths[0]!),
await createFile(userId, grandchildId, paths[1]!),
];
const otherFileId = await createFile(userId, otherId, paths[2]!);
await UploadRepo.createFileUploadSession(db, {
...newEntryParams(userId, childId),
id: crypto.randomUUID(),
path: paths[3]!,
totalChunks: 1,
expiresAt: new Date(Date.now() + 60_000),
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
});
const { deletedFileIds } = await DirectoryService.deleteDirectory(userId, rootId);
expect(deletedFileIds.sort()).toEqual(fileIds.sort());
expect(await getQueuedDeletions()).toEqual([paths[0], paths[1], paths[3]].sort());
await expect(DirectoryService.getDirectory(userId, grandchildId)).rejects.toMatchObject({
code: "NOT_FOUND",
});
expect((await FileService.getFile(userId, otherFileId)).id).toBe(otherFileId);
});
it("rejects deleting another user's directory", async () => {
const { userId: ownerId } = await createUser();
const { userId: otherId } = await createUser();
const directoryId = await createDirectory(ownerId);
await expect(DirectoryService.deleteDirectory(otherId, directoryId)).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
});
+71
View File
@@ -0,0 +1,71 @@
import { mkdtemp, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { buffer } from "stream/consumers";
import type { ReadableStream } from "stream/web";
import { describe, expect, it } from "vitest";
import { createEncContentStream } from "#lib/server/storage/index.ts";
const createFile = async (content: string) => {
const path = join(await mkdtemp(join(tmpdir(), "arkvault-test-")), "file");
await writeFile(path, content);
return path;
};
const read = async (stream: unknown) => (await buffer(stream as ReadableStream)).toString();
describe("createEncContentStream", () => {
it("serves the whole content when no range is requested", async () => {
const path = await createFile("0123456789");
const { encContentStream, range, isRangeRequest } = await createEncContentStream(path);
expect(isRangeRequest).toBe(false);
expect(range).toEqual({ start: 0, end: 9, total: 10 });
expect(await read(encContentStream)).toBe("0123456789");
});
it("serves the requested range", async () => {
const path = await createFile("0123456789");
const { encContentStream, range, isRangeRequest } = await createEncContentStream(
path,
undefined,
{ start: 2, end: 5 },
);
expect(isRangeRequest).toBe(true);
expect(range).toEqual({ start: 2, end: 5, total: 10 });
expect(await read(encContentStream)).toBe("2345");
});
it("serves suffix ranges", async () => {
const path = await createFile("0123456789");
const { encContentStream, range } = await createEncContentStream(path, undefined, {
suffixLength: 3,
});
expect(range).toEqual({ start: 7, end: 9, total: 10 });
expect(await read(encContentStream)).toBe("789");
});
it("rejects unsatisfiable ranges", async () => {
const path = await createFile("0123456789");
await expect(createEncContentStream(path, undefined, { start: 10 })).rejects.toMatchObject({
code: "RANGE_NOT_SATISFIABLE",
});
});
it("prepends the legacy IV to the content", async () => {
const path = await createFile("6789");
const { encContentStream } = await createEncContentStream(path, Buffer.from("012345"), {
start: 4,
end: 7,
});
expect(await read(encContentStream)).toBe("4567");
});
});
+61
View File
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it } from "vitest";
import { CategoryService, DirectoryService, UploadService } from "#lib/server/services/index.ts";
import {
createCategory,
createDirectory,
createUser,
newEntryParams,
resetDatabase,
ciphertext,
} from "../helpers/database.ts";
beforeEach(resetDatabase);
describe("parent ownership", () => {
it("allows creating a directory under the user's own directory", async () => {
const { userId } = await createUser();
const parentId = await createDirectory(userId);
await DirectoryService.createDirectory(newEntryParams(userId, parentId));
const { subDirectories } = await DirectoryService.getDirectory(userId, parentId);
expect(subDirectories).toHaveLength(1);
});
it("rejects creating a directory under another user's directory", async () => {
const { userId: victimId } = await createUser();
const { userId: attackerId } = await createUser();
const victimDirectoryId = await createDirectory(victimId);
await expect(
DirectoryService.createDirectory(newEntryParams(attackerId, victimDirectoryId)),
).rejects.toMatchObject({ code: "NOT_FOUND", message: "Directory not found" });
});
it("rejects creating a category under another user's category", async () => {
const { userId: victimId } = await createUser();
const { userId: attackerId } = await createUser();
const victimCategoryId = await createCategory(victimId);
await expect(
CategoryService.createCategory(newEntryParams(attackerId, victimCategoryId)),
).rejects.toMatchObject({ code: "NOT_FOUND", message: "Category not found" });
});
it("rejects uploading a file into another user's directory", async () => {
const { userId: victimId } = await createUser();
const { userId: attackerId } = await createUser();
const victimDirectoryId = await createDirectory(victimId);
await expect(
UploadService.startFileUpload({
...newEntryParams(attackerId, victimDirectoryId),
totalChunks: 1,
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
}),
).rejects.toMatchObject({ code: "NOT_FOUND", message: "Directory not found" });
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { RequestEvent } from "@sveltejs/kit";
import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it } from "vitest";
import { appRouter } from "#trpc/router.server.ts";
import { createDirectory, createUser, resetDatabase } from "../helpers/database.ts";
const createCaller = (userId: number, clientId?: number) =>
appRouter.createCaller({
locals: { ip: "127.0.0.1", userAgent: "test", session: { id: "session", userId, clientId } },
} as RequestEvent);
beforeEach(resetDatabase);
describe("tRPC error mapping", () => {
it("maps integrity errors to tRPC errors", async () => {
const { userId: ownerId } = await createUser();
const { userId, clientId } = await createUser();
const directoryId = await createDirectory(ownerId);
const error = await createCaller(userId, clientId)
.directory.create({
parent: directoryId,
mekVersion: 1,
dek: "ZGVr",
dekVersion: new Date(),
name: "bmFtZQ==",
nameIv: "aXY=",
})
.catch((e) => e);
expect(error).toBeInstanceOf(TRPCError);
expect(error).toMatchObject({ code: "NOT_FOUND", message: "Directory not found" });
});
it("maps service errors to tRPC errors", async () => {
const { userId, clientId } = await createUser();
await expect(
createCaller(userId, clientId).directory.create({
parent: "root",
mekVersion: 1,
dek: "ZGVr",
dekVersion: new Date(Date.now() - 3600 * 1000),
name: "bmFtZQ==",
nameIv: "aXY=",
}),
).rejects.toMatchObject({ code: "BAD_REQUEST", message: "Invalid DEK version" });
});
it("rejects clients that are not active", async () => {
const { userId } = await createUser();
await expect(createCaller(userId).file.list()).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
+157
View File
@@ -0,0 +1,157 @@
import { createHash } from "crypto";
import { Readable } from "stream";
import { beforeEach, describe, expect, it } from "vitest";
import { UploadRepo, db } from "#lib/server/db/index.ts";
import { FileService, UploadService } from "#lib/server/services/index.ts";
import { isChunkUploaded } from "#lib/server/services/upload.ts";
import { hashFile } from "#lib/server/storage/index.ts";
import { ciphertext, createUser, newEntryParams, resetDatabase } from "../helpers/database.ts";
beforeEach(resetDatabase);
describe("upload session bitmap", () => {
it("matches the bit order of PostgreSQL's set_bit", async () => {
const { userId } = await createUser();
const id = crypto.randomUUID();
await UploadRepo.createFileUploadSession(db, {
...newEntryParams(userId, "root"),
id,
path: "unused",
totalChunks: 20,
expiresAt: new Date(Date.now() + 60_000),
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
});
const uploaded = [1, 8, 9, 20];
for (const chunkIndex of uploaded) {
await UploadRepo.markChunkAsUploaded(db, id, chunkIndex);
}
const session = await UploadRepo.getUploadSession(db, id, userId);
expect(session?.uploadedChunks).toBe(uploaded.length);
for (let chunkIndex = 1; chunkIndex <= 20; chunkIndex++) {
expect(isChunkUploaded(session!.bitmap, chunkIndex)).toBe(uploaded.includes(chunkIndex));
}
});
});
describe("upload completion", () => {
it.each([false, true])(
"rejects a late duplicate without changing the file (completed: %s)",
async (completeBeforeDuplicate) => {
const { userId } = await createUser();
const { uploadId } = await UploadService.startFileUpload({
...newEntryParams(userId, "root"),
totalChunks: 1,
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
});
const session = (await UploadRepo.getUploadSession(db, uploadId, userId))!;
const started = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
const duplicate = UploadService.uploadChunk(
userId,
uploadId,
1,
Readable.from(
(async function* () {
started.resolve();
await release.promise;
yield Buffer.alloc(100, 2);
})(),
),
);
await started.promise;
try {
const chunk = Buffer.alloc(100, 1);
const hash = createHash("sha256").update(chunk).digest("base64");
await UploadService.uploadChunk(userId, uploadId, 1, Readable.from([chunk]));
if (completeBeforeDuplicate) {
await UploadService.completeFileUpload(userId, uploadId, null, hash);
}
const rejected = expect(duplicate).rejects.toMatchObject({
code: completeBeforeDuplicate ? "NOT_FOUND" : "CONFLICT",
});
release.resolve();
await rejected;
expect(await hashFile(session.path)).toBe(hash);
if (!completeBeforeDuplicate) {
await UploadService.completeFileUpload(userId, uploadId, null, hash);
}
expect(await FileService.getAllFileIds(userId)).toHaveLength(1);
} finally {
release.resolve();
await duplicate.catch(() => {});
}
},
);
it("registers the file only once when completed concurrently", async () => {
const { userId } = await createUser();
const { uploadId } = await UploadService.startFileUpload({
...newEntryParams(userId, "root"),
totalChunks: 1,
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
});
const chunk = Buffer.alloc(100, 1);
await UploadService.uploadChunk(userId, uploadId, 1, Readable.from([chunk]));
const hash = createHash("sha256").update(chunk).digest("base64");
const results = await Promise.allSettled([
UploadService.completeFileUpload(userId, uploadId, null, hash),
UploadService.completeFileUpload(userId, uploadId, null, hash),
]);
expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1);
expect(results.find(({ status }) => status === "rejected")).toMatchObject({
reason: { code: "NOT_FOUND" },
});
expect(await FileService.getAllFileIds(userId)).toHaveLength(1);
});
it("discards corrupted uploads", async () => {
const { userId } = await createUser();
const { uploadId } = await UploadService.startFileUpload({
...newEntryParams(userId, "root"),
totalChunks: 1,
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
});
await UploadService.uploadChunk(userId, uploadId, 1, Readable.from([Buffer.alloc(100)]));
await expect(
UploadService.completeFileUpload(userId, uploadId, null, "invalid"),
).rejects.toMatchObject({ code: "CONFLICT" });
expect(await UploadRepo.getUploadSession(db, uploadId, userId)).toBeNull();
});
it("rejects chunks of invalid sizes", async () => {
const { userId } = await createUser();
const { uploadId } = await UploadService.startFileUpload({
...newEntryParams(userId, "root"),
totalChunks: 2,
hskVersion: null,
contentType: "image/png",
encCreatedAt: null,
encLastModifiedAt: ciphertext(),
});
await expect(
UploadService.uploadChunk(userId, uploadId, 1, Readable.from([Buffer.alloc(100)])),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
});