mirror of
https://github.com/Nezumi-2711/google-drive-s3.git
synced 2026-09-22 13:38:30 +00:00
347 lines
14 KiB
TypeScript
347 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import worker from "../src/index";
|
|
import type { Env } from "../src/types";
|
|
import { bytes, FakeDrive, fakeMd5 } from "./fake-drive";
|
|
|
|
import { env } from "cloudflare:test";
|
|
|
|
const ENV = env as unknown as Env;
|
|
const ENDPOINT = "https://s3-api.example.com";
|
|
const CTX = { waitUntil: vi.fn(), passThroughOnException: vi.fn() } as unknown as ExecutionContext;
|
|
|
|
let drive: FakeDrive;
|
|
let validToken: string;
|
|
|
|
beforeEach(async () => {
|
|
drive = new FakeDrive();
|
|
const rootFolderId = "folder-root";
|
|
drive.folders.set(rootFolderId, { id: rootFolderId, name: "s3-storage", parent: "root" });
|
|
const testBucketId = "folder-test-bucket";
|
|
drive.folders.set(testBucketId, { id: testBucketId, name: "test-bucket", parent: rootFolderId });
|
|
const emptyBucketId = "folder-empty-bucket";
|
|
drive.folders.set(emptyBucketId, { id: emptyBucketId, name: "empty-bucket", parent: rootFolderId });
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn((input, init) => drive.handle(input, init)),
|
|
);
|
|
await ENV.AUTH_KV.delete("google_access_token");
|
|
for (const { name } of (await ENV.FOLDER_CACHE.list()).keys) await ENV.FOLDER_CACHE.delete(name);
|
|
|
|
// Create session token
|
|
const token = "test-session-token-12345";
|
|
const data = new TextEncoder().encode(token);
|
|
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
const hashHex = Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
await ENV.AUTH_KV.put(`session:${hashHex}`, JSON.stringify({ createdAt: Date.now() }), { expirationTtl: 3600 });
|
|
validToken = token;
|
|
});
|
|
|
|
describe("Object API (/api/objects)", () => {
|
|
it("rejects requests without valid auth", async () => {
|
|
const res = await worker.fetch(new Request(`${ENDPOINT}/api/objects?bucket=test-bucket`), ENV, CTX);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("returns 404 for unknown bucket", async () => {
|
|
const res = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects?bucket=nonexistent`, {
|
|
headers: { Authorization: `Bearer ${validToken}` },
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(res.status).toBe(404);
|
|
const data = await res.json<{ message: string }>();
|
|
expect(data.message).toContain("not found");
|
|
});
|
|
|
|
it("lists objects and folders with delimiter", async () => {
|
|
// Create files in test-bucket
|
|
drive.files.set("file-1", {
|
|
id: "file-1",
|
|
name: "root-file.txt",
|
|
parent: "folder-test-bucket",
|
|
mimeType: "text/plain",
|
|
data: new TextEncoder().encode("hello"),
|
|
md5Checksum: fakeMd5(new TextEncoder().encode("hello")),
|
|
modifiedTime: "2026-08-16T08:00:00.000Z",
|
|
trashed: false,
|
|
});
|
|
drive.folders.set("folder-sub", {
|
|
id: "folder-sub",
|
|
name: "photos",
|
|
parent: "folder-test-bucket",
|
|
trashed: false,
|
|
});
|
|
drive.files.set("file-2", {
|
|
id: "file-2",
|
|
name: "pic.jpg",
|
|
parent: "folder-sub",
|
|
mimeType: "image/jpeg",
|
|
data: new Uint8Array([1, 2, 3]),
|
|
md5Checksum: fakeMd5(new Uint8Array([1, 2, 3])),
|
|
modifiedTime: "2026-08-16T08:00:00.000Z",
|
|
trashed: false,
|
|
});
|
|
|
|
const res = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects?bucket=test-bucket&prefix=&delimiter=/`, {
|
|
headers: { Authorization: `Bearer ${validToken}` },
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const data = await res.json<{
|
|
bucket: string;
|
|
prefix: string;
|
|
delimiter: string;
|
|
folders: Array<{ prefix: string; name: string }>;
|
|
objects: Array<{ key: string; name: string; size: number; contentType: string }>;
|
|
truncated: boolean;
|
|
}>();
|
|
|
|
expect(data.bucket).toBe("test-bucket");
|
|
expect(data.folders).toEqual([{ prefix: "photos/", name: "photos" }]);
|
|
expect(data.objects).toHaveLength(1);
|
|
expect(data.objects[0].name).toBe("root-file.txt");
|
|
expect(data.objects[0].key).toBe("root-file.txt");
|
|
expect(data.truncated).toBe(false);
|
|
|
|
// List nested folder
|
|
const subRes = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects?bucket=test-bucket&prefix=photos/&delimiter=/`, {
|
|
headers: { Authorization: `Bearer ${validToken}` },
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(subRes.status).toBe(200);
|
|
const subData = await subRes.json<{
|
|
folders: Array<{ prefix: string; name: string }>;
|
|
objects: Array<{ key: string; name: string; size: number }>;
|
|
}>();
|
|
expect(subData.folders).toEqual([]);
|
|
expect(subData.objects).toHaveLength(1);
|
|
expect(subData.objects[0].name).toBe("pic.jpg");
|
|
expect(subData.objects[0].key).toBe("photos/pic.jpg");
|
|
});
|
|
|
|
it("gets object metadata", async () => {
|
|
drive.files.set("file-1", {
|
|
id: "file-1",
|
|
name: "test.txt",
|
|
parent: "folder-test-bucket",
|
|
mimeType: "text/plain",
|
|
data: new TextEncoder().encode("content"),
|
|
md5Checksum: fakeMd5(new TextEncoder().encode("content")),
|
|
modifiedTime: "2026-08-16T08:00:00.000Z",
|
|
trashed: false,
|
|
});
|
|
|
|
const res = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects/metadata?bucket=test-bucket&key=test.txt`, {
|
|
headers: { Authorization: `Bearer ${validToken}` },
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const data = await res.json<{ key: string; name: string; size: number; contentType: string }>();
|
|
expect(data.key).toBe("test.txt");
|
|
expect(data.name).toBe("test.txt");
|
|
expect(data.size).toBe(7);
|
|
expect(data.contentType).toBe("text/plain");
|
|
});
|
|
|
|
it("direct PUT and GET content", async () => {
|
|
const payload = new Uint8Array([10, 20, 30, 40]);
|
|
const putRes = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects/content?bucket=test-bucket&key=binary.bin`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${validToken}`,
|
|
"Content-Type": "application/octet-stream",
|
|
},
|
|
body: payload,
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(putRes.status).toBe(200);
|
|
const putData = await putRes.json<{ key: string; etag: string; size: number }>();
|
|
expect(putData.key).toBe("binary.bin");
|
|
|
|
const getRes = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects/content?bucket=test-bucket&key=binary.bin`, {
|
|
headers: { Authorization: `Bearer ${validToken}` },
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(getRes.status).toBe(200);
|
|
expect(getRes.headers.get("Content-Disposition")).toContain("binary.bin");
|
|
const body = new Uint8Array(await getRes.arrayBuffer());
|
|
expect(body).toEqual(payload);
|
|
});
|
|
|
|
it("creates and deletes folders with empty / non-empty checks", async () => {
|
|
// Create folder
|
|
const createRes = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects/folder`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${validToken}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ bucket: "test-bucket", prefix: "my-folder/" }),
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(createRes.status).toBe(201);
|
|
|
|
// Put a file inside folder
|
|
await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects/content?bucket=test-bucket&key=my-folder/item.txt`, {
|
|
method: "PUT",
|
|
headers: { Authorization: `Bearer ${validToken}` },
|
|
body: "hello",
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
|
|
// Try deleting non-empty folder without recursive=1 -> 409
|
|
const delRes409 = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects/folder?bucket=test-bucket&prefix=my-folder/`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${validToken}` },
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(delRes409.status).toBe(409);
|
|
|
|
// Delete with recursive=1 -> 204
|
|
const delRes204 = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects/folder?bucket=test-bucket&prefix=my-folder/&recursive=1`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${validToken}` },
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(delRes204.status).toBe(204);
|
|
});
|
|
|
|
it("supports download tickets without session token", async () => {
|
|
await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects/content?bucket=test-bucket&key=ticket-doc.pdf`, {
|
|
method: "PUT",
|
|
headers: { Authorization: `Bearer ${validToken}` },
|
|
body: "pdf contents here",
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
|
|
// Create download ticket
|
|
const ticketRes = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects/download-ticket`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${validToken}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ bucket: "test-bucket", key: "ticket-doc.pdf" }),
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(ticketRes.status).toBe(201);
|
|
const { ticket, downloadUrl } = await ticketRes.json<{ ticket: string; downloadUrl: string }>();
|
|
expect(ticket).toBeDefined();
|
|
|
|
// Download without Authorization header using ticket
|
|
const dlRes = await worker.fetch(new Request(`${ENDPOINT}${downloadUrl}`), ENV, CTX);
|
|
expect(dlRes.status).toBe(200);
|
|
expect(await dlRes.text()).toBe("pdf contents here");
|
|
|
|
// Second attempt with same ticket should fail (single use)
|
|
const dlRes2 = await worker.fetch(new Request(`${ENDPOINT}${downloadUrl}`), ENV, CTX);
|
|
expect(dlRes2.status).toBe(403);
|
|
});
|
|
|
|
it("performs full multipart upload flow via JSON API with non-aligned parts", async () => {
|
|
// 1. Initiate multipart
|
|
const initRes = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects/uploads`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${validToken}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ bucket: "test-bucket", key: "large-video.mp4", contentType: "video/mp4" }),
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(initRes.status).toBe(201);
|
|
const { uploadId, partSize } = await initRes.json<{ uploadId: string; partSize: number }>();
|
|
expect(uploadId).toBeDefined();
|
|
expect(partSize).toBe(8 * 1024 * 1024);
|
|
|
|
// 2. Upload part 1: 300 KiB (non-aligned to 256 KiB)
|
|
const part1Data = bytes(300 * 1024, 11);
|
|
const part1Res = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects/uploads/part?bucket=test-bucket&key=large-video.mp4&uploadId=${encodeURIComponent(uploadId)}&partNumber=1`, {
|
|
method: "PUT",
|
|
headers: { Authorization: `Bearer ${validToken}`, "Content-Length": String(part1Data.byteLength) },
|
|
body: part1Data,
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(part1Res.status).toBe(200);
|
|
const part1Json = await part1Res.json<{ partNumber: number; etag: string }>();
|
|
|
|
// 3. Upload part 2: 200 KiB (non-aligned)
|
|
const part2Data = bytes(200 * 1024, 22);
|
|
const part2Res = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects/uploads/part?bucket=test-bucket&key=large-video.mp4&uploadId=${encodeURIComponent(uploadId)}&partNumber=2`, {
|
|
method: "PUT",
|
|
headers: { Authorization: `Bearer ${validToken}`, "Content-Length": String(part2Data.byteLength) },
|
|
body: part2Data,
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(part2Res.status).toBe(200);
|
|
const part2Json = await part2Res.json<{ partNumber: number; etag: string }>();
|
|
|
|
// 4. Complete multipart
|
|
const completeRes = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/objects/uploads/complete`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${validToken}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
bucket: "test-bucket",
|
|
key: "large-video.mp4",
|
|
uploadId,
|
|
parts: [
|
|
{ partNumber: 1, etag: part1Json.etag },
|
|
{ partNumber: 2, etag: part2Json.etag },
|
|
],
|
|
}),
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(completeRes.status).toBe(200);
|
|
const completeJson = await completeRes.json<{ key: string; etag: string }>();
|
|
expect(completeJson.key).toBe("large-video.mp4");
|
|
|
|
// Verify stored data
|
|
const storedFile = [...drive.files.values()].find((f) => f.name === "large-video.mp4");
|
|
expect(storedFile).toBeDefined();
|
|
const expectedBytes = new Uint8Array(part1Data.byteLength + part2Data.byteLength);
|
|
expectedBytes.set(part1Data, 0);
|
|
expectedBytes.set(part2Data, part1Data.byteLength);
|
|
expect(storedFile!.data).toEqual(expectedBytes);
|
|
});
|
|
});
|