mirror of
https://github.com/Nezumi-2711/google-drive-s3.git
synced 2026-09-22 13:38:30 +00:00
feat: add api for manage bucket
This commit is contained in:
+1
-1
@@ -58,7 +58,7 @@ describe("CORS", () => {
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(ORIGIN);
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, HEAD, PUT, POST, DELETE, OPTIONS");
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, HEAD, PUT, POST, PATCH, DELETE, OPTIONS");
|
||||
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("content-type,x-amz-date");
|
||||
expect(response.headers.get("Access-Control-Max-Age")).toBe("86400");
|
||||
expect(response.headers.get("Vary")).toBe("Origin");
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { expect } from "vitest";
|
||||
|
||||
export interface StoredFile {
|
||||
id: string;
|
||||
name: string;
|
||||
parent: string;
|
||||
mimeType: string;
|
||||
data: Uint8Array;
|
||||
md5Checksum: string;
|
||||
modifiedTime: string;
|
||||
trashed?: boolean;
|
||||
}
|
||||
|
||||
export const FAKE_MODIFIED_TIME = "2026-08-16T08:00:00.000Z";
|
||||
|
||||
export interface StoredFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
parent: string;
|
||||
trashed?: boolean;
|
||||
}
|
||||
|
||||
export const FOLDER_MIME = "application/vnd.google-apps.folder";
|
||||
|
||||
export interface UploadSession {
|
||||
id: string;
|
||||
fileId?: string;
|
||||
name: string;
|
||||
parent: string;
|
||||
mimeType: string;
|
||||
committed: Uint8Array;
|
||||
}
|
||||
|
||||
export class FakeDrive {
|
||||
readonly files = new Map<string, StoredFile>();
|
||||
readonly folders = new Map<string, StoredFolder>();
|
||||
readonly sessions = new Map<string, UploadSession>();
|
||||
private nextId = 1;
|
||||
|
||||
async handle(input: string | URL | Request, init?: RequestInit): Promise<Response> {
|
||||
const request = input instanceof Request ? input : new Request(input, init);
|
||||
const url = new URL(request.url);
|
||||
if (url.hostname === "oauth2.googleapis.com") return Response.json({ access_token: "token", expires_in: 3600 });
|
||||
if (url.hostname !== "www.googleapis.com") return new Response("Not Found", { status: 404 });
|
||||
|
||||
if (url.pathname === "/drive/v3/files" && request.method === "GET") return this.search(url);
|
||||
if (url.pathname === "/drive/v3/files" && request.method === "POST") return this.createMetadata(await request.json<Record<string, unknown>>());
|
||||
if (url.pathname.startsWith("/drive/v3/files/") && url.searchParams.get("alt") === "media") return this.download(url, request);
|
||||
if (url.pathname.startsWith("/drive/v3/files/") && request.method === "PATCH") {
|
||||
const fileId = url.pathname.split("/").at(-1);
|
||||
if (!fileId) return new Response("Not Found", { status: 404 });
|
||||
const body = await request.json<Record<string, unknown>>();
|
||||
return this.patchFile(fileId, body, url);
|
||||
}
|
||||
if (url.pathname.startsWith("/drive/v3/files/") && request.method === "DELETE") {
|
||||
const fileId = url.pathname.split("/").at(-1);
|
||||
if (!fileId) return new Response("Not Found", { status: 404 });
|
||||
this.files.delete(fileId);
|
||||
this.folders.delete(fileId);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url.pathname.startsWith("/upload/drive/v3/files") && url.searchParams.get("uploadType") === "resumable") return this.initialize(url, request);
|
||||
if (url.pathname.startsWith("/upload/session/")) return this.upload(url, request);
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
private search(url: URL): Response {
|
||||
const q = url.searchParams.get("q") ?? "";
|
||||
const hasNameFilter = /name='/.test(q);
|
||||
const name = /name='((?:\\.|[^'])*)'/.exec(q)?.[1]?.replace(/\\'/g, "'").replace(/\\\\/g, "\\");
|
||||
const parent = /'([^']+)' in parents/.exec(q)?.[1] ?? "root";
|
||||
|
||||
if (q.includes(FOLDER_MIME)) {
|
||||
const match = [...this.folders.values()].find((folder) => (!hasNameFilter || folder.name === name) && folder.parent === parent);
|
||||
return Response.json({ files: match ? [{ id: match.id, name: match.name, mimeType: FOLDER_MIME }] : [] });
|
||||
}
|
||||
|
||||
const files = [...this.files.values()].filter((file) => (!hasNameFilter || file.name === name) && file.parent === parent);
|
||||
const folders = [...this.folders.values()].filter((folder) => (!hasNameFilter || folder.name === name) && folder.parent === parent);
|
||||
return Response.json({
|
||||
files: [...files.map((file) => ({ ...file, size: String(file.data.byteLength), data: undefined })), ...folders.map((folder) => ({ id: folder.id, name: folder.name, mimeType: FOLDER_MIME }))],
|
||||
});
|
||||
}
|
||||
|
||||
private createMetadata(metadata: Record<string, unknown>): Response {
|
||||
const mimeType = String(metadata.mimeType ?? "application/octet-stream");
|
||||
const parent = String((metadata.parents as string[] | undefined)?.[0] ?? "root");
|
||||
if (mimeType === FOLDER_MIME) {
|
||||
const id = `folder-${this.nextId++}`;
|
||||
this.folders.set(id, { id, name: String(metadata.name), parent, trashed: false });
|
||||
return Response.json({ id, name: metadata.name, mimeType });
|
||||
}
|
||||
const id = `file-${this.nextId++}`;
|
||||
const file: StoredFile = {
|
||||
id,
|
||||
name: String(metadata.name),
|
||||
parent,
|
||||
mimeType,
|
||||
data: new Uint8Array(),
|
||||
md5Checksum: "d41d8cd98f00b204e9800998ecf8427e",
|
||||
modifiedTime: FAKE_MODIFIED_TIME,
|
||||
trashed: false,
|
||||
};
|
||||
this.files.set(id, file);
|
||||
return Response.json({ ...file, size: "0", data: undefined });
|
||||
}
|
||||
|
||||
private patchFile(fileId: string, body: Record<string, unknown>, url: URL): Response {
|
||||
if (this.folders.has(fileId)) {
|
||||
const folder = this.folders.get(fileId)!;
|
||||
if (body.trashed !== undefined) folder.trashed = Boolean(body.trashed);
|
||||
if (body.name !== undefined) folder.name = String(body.name);
|
||||
return Response.json({ id: folder.id, name: folder.name, mimeType: FOLDER_MIME, trashed: folder.trashed });
|
||||
}
|
||||
if (this.files.has(fileId)) {
|
||||
const file = this.files.get(fileId)!;
|
||||
if (body.trashed !== undefined) file.trashed = Boolean(body.trashed);
|
||||
if (body.name !== undefined) file.name = String(body.name);
|
||||
return Response.json({ id: file.id, name: file.name, mimeType: file.mimeType, size: String(file.data.byteLength), trashed: file.trashed });
|
||||
}
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
private async initialize(url: URL, request: Request): Promise<Response> {
|
||||
const metadata = await request.json<{ name: string; parents?: string[] }>();
|
||||
const pathId = url.pathname.split("/").at(-1);
|
||||
const fileId = pathId === "files" ? undefined : pathId;
|
||||
const id = `session-${this.nextId++}`;
|
||||
this.sessions.set(id, {
|
||||
id,
|
||||
fileId,
|
||||
name: metadata.name,
|
||||
parent: metadata.parents?.[0] ?? (fileId ? this.files.get(fileId)?.parent : "") ?? "",
|
||||
mimeType: request.headers.get("X-Upload-Content-Type") ?? "application/octet-stream",
|
||||
committed: new Uint8Array(),
|
||||
});
|
||||
return new Response(null, { status: 200, headers: { Location: `https://www.googleapis.com/upload/session/${id}` } });
|
||||
}
|
||||
|
||||
private async upload(url: URL, request: Request): Promise<Response> {
|
||||
const id = url.pathname.split("/").at(-1);
|
||||
if (!id) return new Response(null, { status: 404 });
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return new Response(null, { status: 404 });
|
||||
if (request.method === "DELETE") {
|
||||
this.sessions.delete(id);
|
||||
return new Response(null, { status: 499 });
|
||||
}
|
||||
const range = request.headers.get("Content-Range");
|
||||
if (range === "bytes */*") return new Response(null, { status: 308, headers: this.rangeHeaders(session.committed.byteLength) });
|
||||
const body = new Uint8Array(await request.arrayBuffer());
|
||||
if (!range) return this.finalize(session, body);
|
||||
const match = /^bytes (\d+)-(\d+)\/(\*|\d+)$/.exec(range);
|
||||
if (!match) return new Response("Bad range", { status: 400 });
|
||||
const start = Number(match[1]);
|
||||
const end = Number(match[2]);
|
||||
const total = match[3] === "*" ? null : Number(match[3]);
|
||||
expect(start).toBe(session.committed.byteLength);
|
||||
expect(end - start + 1).toBe(body.byteLength);
|
||||
if (total === null) expect(body.byteLength % (256 * 1024)).toBe(0);
|
||||
session.committed = concat(session.committed, body);
|
||||
if (total === null) return new Response(null, { status: 308, headers: this.rangeHeaders(session.committed.byteLength) });
|
||||
expect(session.committed.byteLength).toBe(total);
|
||||
return this.finalize(session, session.committed);
|
||||
}
|
||||
|
||||
private rangeHeaders(length: number): HeadersInit {
|
||||
return length === 0 ? {} : { Range: `bytes=0-${length - 1}` };
|
||||
}
|
||||
|
||||
private finalize(session: UploadSession, data: Uint8Array): Response {
|
||||
const id = session.fileId ?? `file-${this.nextId++}`;
|
||||
const file: StoredFile = { id, name: session.name, parent: session.parent, mimeType: session.mimeType, data, md5Checksum: fakeMd5(data), modifiedTime: FAKE_MODIFIED_TIME, trashed: false };
|
||||
this.files.set(id, file);
|
||||
this.sessions.delete(session.id);
|
||||
return Response.json({ ...file, size: String(data.byteLength), data: undefined });
|
||||
}
|
||||
|
||||
private download(url: URL, request: Request): Response {
|
||||
const fileId = url.pathname.split("/").at(-1);
|
||||
if (!fileId) return new Response(null, { status: 404 });
|
||||
const file = this.files.get(fileId);
|
||||
if (!file) return new Response(null, { status: 404 });
|
||||
const range = request.headers.get("Range");
|
||||
if (!range) return new Response(file.data, { headers: { "Content-Length": String(file.data.byteLength) } });
|
||||
const match = /^bytes=(\d+)-(\d+)?$/.exec(range);
|
||||
if (!match) return new Response(null, { status: 416 });
|
||||
const start = Number(match[1]);
|
||||
const end = Math.min(file.data.byteLength - 1, match[2] ? Number(match[2]) : file.data.byteLength - 1);
|
||||
const data = file.data.slice(start, end + 1);
|
||||
return new Response(data, { status: 206, headers: { "Content-Length": String(data.byteLength), "Content-Range": `bytes ${start}-${end}/${file.data.byteLength}` } });
|
||||
}
|
||||
}
|
||||
|
||||
export function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
const output = new Uint8Array(a.byteLength + b.byteLength);
|
||||
output.set(a);
|
||||
output.set(b, a.byteLength);
|
||||
return output;
|
||||
}
|
||||
|
||||
export function fakeMd5(data: Uint8Array): string {
|
||||
let state = 0x811c9dc5;
|
||||
for (const byte of data) state = Math.imul(state ^ byte, 0x01000193);
|
||||
return (state >>> 0).toString(16).padStart(8, "0").repeat(4);
|
||||
}
|
||||
|
||||
export function bytes(length: number, seed = 17): Uint8Array {
|
||||
const output = new Uint8Array(length);
|
||||
let state = seed;
|
||||
for (let index = 0; index < length; index++) {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) | 0;
|
||||
output[index] = state >>> 24;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
+1
-189
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { decodedBodyChunks } from "../src/aws-chunked";
|
||||
import worker from "../src/index";
|
||||
import type { Env } from "../src/types";
|
||||
import { bytes, FAKE_MODIFIED_TIME, fakeMd5, FakeDrive } from "./fake-drive";
|
||||
|
||||
import { env } from "cloudflare:test";
|
||||
|
||||
@@ -11,195 +12,6 @@ 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;
|
||||
|
||||
interface StoredFile {
|
||||
id: string;
|
||||
name: string;
|
||||
parent: string;
|
||||
mimeType: string;
|
||||
data: Uint8Array;
|
||||
md5Checksum: string;
|
||||
modifiedTime: string;
|
||||
}
|
||||
|
||||
const FAKE_MODIFIED_TIME = "2026-08-16T08:00:00.000Z";
|
||||
|
||||
interface StoredFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
parent: string;
|
||||
}
|
||||
|
||||
const FOLDER_MIME = "application/vnd.google-apps.folder";
|
||||
|
||||
interface UploadSession {
|
||||
id: string;
|
||||
fileId?: string;
|
||||
name: string;
|
||||
parent: string;
|
||||
mimeType: string;
|
||||
committed: Uint8Array;
|
||||
}
|
||||
|
||||
class FakeDrive {
|
||||
readonly files = new Map<string, StoredFile>();
|
||||
readonly folders = new Map<string, StoredFolder>();
|
||||
readonly sessions = new Map<string, UploadSession>();
|
||||
private nextId = 1;
|
||||
|
||||
async handle(input: string | URL | Request, init?: RequestInit): Promise<Response> {
|
||||
const request = input instanceof Request ? input : new Request(input, init);
|
||||
const url = new URL(request.url);
|
||||
if (url.hostname === "oauth2.googleapis.com") return Response.json({ access_token: "token", expires_in: 3600 });
|
||||
if (url.hostname !== "www.googleapis.com") return new Response("Not Found", { status: 404 });
|
||||
|
||||
if (url.pathname === "/drive/v3/files" && request.method === "GET") return this.search(url);
|
||||
if (url.pathname === "/drive/v3/files" && request.method === "POST") return this.createMetadata(await request.json<Record<string, unknown>>());
|
||||
if (url.pathname.startsWith("/drive/v3/files/") && url.searchParams.get("alt") === "media") return this.download(url, request);
|
||||
if (url.pathname.startsWith("/drive/v3/files/") && request.method === "DELETE") {
|
||||
const fileId = url.pathname.split("/").at(-1);
|
||||
if (!fileId) return new Response("Not Found", { status: 404 });
|
||||
this.files.delete(fileId);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url.pathname.startsWith("/upload/drive/v3/files") && url.searchParams.get("uploadType") === "resumable") return this.initialize(url, request);
|
||||
if (url.pathname.startsWith("/upload/session/")) return this.upload(url, request);
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
private search(url: URL): Response {
|
||||
const q = url.searchParams.get("q") ?? "";
|
||||
const hasNameFilter = /name='/.test(q);
|
||||
const name = /name='((?:\\.|[^'])*)'/.exec(q)?.[1]?.replace(/\\'/g, "'").replace(/\\\\/g, "\\");
|
||||
const parent = /'([^']+)' in parents/.exec(q)?.[1] ?? "root";
|
||||
|
||||
if (q.includes(FOLDER_MIME)) {
|
||||
const match = [...this.folders.values()].find((folder) => folder.name === name && folder.parent === parent);
|
||||
return Response.json({ files: match ? [{ id: match.id, name: match.name, mimeType: FOLDER_MIME }] : [] });
|
||||
}
|
||||
|
||||
const files = [...this.files.values()].filter((file) => (!hasNameFilter || file.name === name) && file.parent === parent);
|
||||
const folders = [...this.folders.values()].filter((folder) => (!hasNameFilter || folder.name === name) && folder.parent === parent);
|
||||
return Response.json({
|
||||
files: [...files.map((file) => ({ ...file, size: String(file.data.byteLength), data: undefined })), ...folders.map((folder) => ({ id: folder.id, name: folder.name, mimeType: FOLDER_MIME }))],
|
||||
});
|
||||
}
|
||||
|
||||
private createMetadata(metadata: Record<string, unknown>): Response {
|
||||
const mimeType = String(metadata.mimeType ?? "application/octet-stream");
|
||||
const parent = String((metadata.parents as string[] | undefined)?.[0] ?? "root");
|
||||
if (mimeType === FOLDER_MIME) {
|
||||
const id = `folder-${this.nextId++}`;
|
||||
this.folders.set(id, { id, name: String(metadata.name), parent });
|
||||
return Response.json({ id, name: metadata.name, mimeType });
|
||||
}
|
||||
const id = `file-${this.nextId++}`;
|
||||
const file: StoredFile = {
|
||||
id,
|
||||
name: String(metadata.name),
|
||||
parent,
|
||||
mimeType,
|
||||
data: new Uint8Array(),
|
||||
md5Checksum: "d41d8cd98f00b204e9800998ecf8427e",
|
||||
modifiedTime: FAKE_MODIFIED_TIME,
|
||||
};
|
||||
this.files.set(id, file);
|
||||
return Response.json({ ...file, size: "0", data: undefined });
|
||||
}
|
||||
|
||||
private async initialize(url: URL, request: Request): Promise<Response> {
|
||||
const metadata = await request.json<{ name: string; parents?: string[] }>();
|
||||
const pathId = url.pathname.split("/").at(-1);
|
||||
const fileId = pathId === "files" ? undefined : pathId;
|
||||
const id = `session-${this.nextId++}`;
|
||||
this.sessions.set(id, {
|
||||
id,
|
||||
fileId,
|
||||
name: metadata.name,
|
||||
parent: metadata.parents?.[0] ?? (fileId ? this.files.get(fileId)?.parent : "") ?? "",
|
||||
mimeType: request.headers.get("X-Upload-Content-Type") ?? "application/octet-stream",
|
||||
committed: new Uint8Array(),
|
||||
});
|
||||
return new Response(null, { status: 200, headers: { Location: `https://www.googleapis.com/upload/session/${id}` } });
|
||||
}
|
||||
|
||||
private async upload(url: URL, request: Request): Promise<Response> {
|
||||
const id = url.pathname.split("/").at(-1);
|
||||
if (!id) return new Response(null, { status: 404 });
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return new Response(null, { status: 404 });
|
||||
if (request.method === "DELETE") {
|
||||
this.sessions.delete(id);
|
||||
return new Response(null, { status: 499 });
|
||||
}
|
||||
const range = request.headers.get("Content-Range");
|
||||
if (range === "bytes */*") return new Response(null, { status: 308, headers: this.rangeHeaders(session.committed.byteLength) });
|
||||
const body = new Uint8Array(await request.arrayBuffer());
|
||||
if (!range) return this.finalize(session, body);
|
||||
const match = /^bytes (\d+)-(\d+)\/(\*|\d+)$/.exec(range);
|
||||
if (!match) return new Response("Bad range", { status: 400 });
|
||||
const start = Number(match[1]);
|
||||
const end = Number(match[2]);
|
||||
const total = match[3] === "*" ? null : Number(match[3]);
|
||||
expect(start).toBe(session.committed.byteLength);
|
||||
expect(end - start + 1).toBe(body.byteLength);
|
||||
if (total === null) expect(body.byteLength % (256 * 1024)).toBe(0);
|
||||
session.committed = concat(session.committed, body);
|
||||
if (total === null) return new Response(null, { status: 308, headers: this.rangeHeaders(session.committed.byteLength) });
|
||||
expect(session.committed.byteLength).toBe(total);
|
||||
return this.finalize(session, session.committed);
|
||||
}
|
||||
|
||||
private rangeHeaders(length: number): HeadersInit {
|
||||
return length === 0 ? {} : { Range: `bytes=0-${length - 1}` };
|
||||
}
|
||||
|
||||
private finalize(session: UploadSession, data: Uint8Array): Response {
|
||||
const id = session.fileId ?? `file-${this.nextId++}`;
|
||||
const file: StoredFile = { id, name: session.name, parent: session.parent, mimeType: session.mimeType, data, md5Checksum: fakeMd5(data), modifiedTime: FAKE_MODIFIED_TIME };
|
||||
this.files.set(id, file);
|
||||
this.sessions.delete(session.id);
|
||||
return Response.json({ ...file, size: String(data.byteLength), data: undefined });
|
||||
}
|
||||
|
||||
private download(url: URL, request: Request): Response {
|
||||
const fileId = url.pathname.split("/").at(-1);
|
||||
if (!fileId) return new Response(null, { status: 404 });
|
||||
const file = this.files.get(fileId);
|
||||
if (!file) return new Response(null, { status: 404 });
|
||||
const range = request.headers.get("Range");
|
||||
if (!range) return new Response(file.data, { headers: { "Content-Length": String(file.data.byteLength) } });
|
||||
const match = /^bytes=(\d+)-(\d+)?$/.exec(range);
|
||||
if (!match) return new Response(null, { status: 416 });
|
||||
const start = Number(match[1]);
|
||||
const end = Math.min(file.data.byteLength - 1, match[2] ? Number(match[2]) : file.data.byteLength - 1);
|
||||
const data = file.data.slice(start, end + 1);
|
||||
return new Response(data, { status: 206, headers: { "Content-Length": String(data.byteLength), "Content-Range": `bytes ${start}-${end}/${file.data.byteLength}` } });
|
||||
}
|
||||
}
|
||||
|
||||
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
const output = new Uint8Array(a.byteLength + b.byteLength);
|
||||
output.set(a);
|
||||
output.set(b, a.byteLength);
|
||||
return output;
|
||||
}
|
||||
|
||||
function fakeMd5(data: Uint8Array): string {
|
||||
let state = 0x811c9dc5;
|
||||
for (const byte of data) state = Math.imul(state ^ byte, 0x01000193);
|
||||
return (state >>> 0).toString(16).padStart(8, "0").repeat(4);
|
||||
}
|
||||
|
||||
function bytes(length: number, seed = 17): Uint8Array {
|
||||
const output = new Uint8Array(length);
|
||||
let state = seed;
|
||||
for (let index = 0; index < length; index++) {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) | 0;
|
||||
output[index] = state >>> 24;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function signed(path: string, init: RequestInit): Promise<Request> {
|
||||
const aws = new AwsClient({ accessKeyId: ENV.ACCESS_KEY, secretAccessKey: ENV.SECRET_KEY, region: ENV.REGION, service: "s3" });
|
||||
const bodyLength = typeof init.body === "string" ? new TextEncoder().encode(init.body).byteLength : init.body instanceof Uint8Array ? init.body.byteLength : undefined;
|
||||
|
||||
Reference in New Issue
Block a user