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,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" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user