mirror of
https://github.com/Nezumi-2711/google-drive-s3.git
synced 2026-09-22 13:38:30 +00:00
366 lines
16 KiB
TypeScript
366 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { sha256 } from "../src/aws-signature";
|
|
import worker from "../src/index";
|
|
import type { Env } from "../src/types";
|
|
|
|
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 testIpCounter = 1;
|
|
function getUniqueIp(): string {
|
|
return `10.0.0.${testIpCounter++}`;
|
|
}
|
|
|
|
async function getValidToken(ip = getUniqueIp()): Promise<string> {
|
|
const passwordHash = await sha256("test-dashboard-password");
|
|
const loginRes = await worker.fetch(
|
|
new Request(`${ENDPOINT}/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "CF-Connecting-IP": ip },
|
|
body: JSON.stringify({ passwordHash }),
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
const data = (await loginRes.json()) as { token: string };
|
|
return data.token;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await (ENV.AUTH_KV as KVNamespace).delete("drive-about");
|
|
await (ENV.FOLDER_CACHE as KVNamespace).delete("bucket-registry");
|
|
for (const { name } of (await (ENV.FOLDER_CACHE as KVNamespace).list()).keys) {
|
|
await (ENV.FOLDER_CACHE as KVNamespace).delete(name);
|
|
}
|
|
});
|
|
|
|
describe("Bucket management CRUD API routes (/api/buckets, /api/import*)", () => {
|
|
it("returns 503 on /api/buckets when DRIVE_ROOT_FOLDER is unset", async () => {
|
|
const token = await getValidToken();
|
|
const customEnv = { ...ENV, DRIVE_ROOT_FOLDER: undefined };
|
|
const res = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/buckets`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
}),
|
|
customEnv,
|
|
CTX,
|
|
);
|
|
expect(res.status).toBe(503);
|
|
const data = (await res.json()) as { message: string };
|
|
expect(data.message).toBe("Storage root folder is not configured");
|
|
});
|
|
|
|
it("rejects reserved bucket names on POST /api/buckets with 400", async () => {
|
|
const token = await getValidToken();
|
|
for (const reserved of ["auth", "api", "docs"]) {
|
|
const res = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/buckets`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ name: reserved }),
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const data = (await res.json()) as { message: string };
|
|
expect(data.message).toContain("reserved");
|
|
}
|
|
});
|
|
|
|
it("rejects invalid bucket names on POST /api/buckets with 400", async () => {
|
|
const token = await getValidToken();
|
|
const invalidNames = ["ab", "Abc", "bucket--name", "-bucket", "bucket-", "192.168.1.1"];
|
|
for (const name of invalidNames) {
|
|
const res = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/buckets`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ name }),
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(res.status).toBe(400);
|
|
}
|
|
});
|
|
|
|
it("creates a bucket with POST /api/buckets and handles conflict with 409", async () => {
|
|
const token = await getValidToken();
|
|
|
|
const fakeFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = new URL(typeof input === "string" ? input : input instanceof Request ? input.url : input.toString());
|
|
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
|
|
if (url.origin === "https://oauth2.googleapis.com") {
|
|
return Response.json({ access_token: "mock-access-token", expires_in: 3600 });
|
|
}
|
|
|
|
if (url.pathname === "/drive/v3/files") {
|
|
const q = url.searchParams.get("q") ?? "";
|
|
if (method === "GET") {
|
|
if (q.includes("name='s3-storage'") && q.includes("'root' in parents")) {
|
|
return Response.json({ files: [{ id: "root-folder-id", name: "s3-storage" }] });
|
|
}
|
|
if (q.includes("'root-folder-id' in parents") && q.includes("mimeType='application/vnd.google-apps.folder'")) {
|
|
return Response.json({ files: [] });
|
|
}
|
|
if (q.includes("name='new-bucket'")) {
|
|
return Response.json({ files: [] });
|
|
}
|
|
return Response.json({ files: [] });
|
|
}
|
|
if (method === "POST") {
|
|
return Response.json({ id: "new-bucket-folder-id" });
|
|
}
|
|
}
|
|
if (url.pathname === "/drive/v3/files/new-bucket-folder-id" && method === "PATCH") {
|
|
return Response.json({ id: "new-bucket-folder-id", name: "new-bucket" });
|
|
}
|
|
return new Response("Not found", { status: 404 });
|
|
});
|
|
|
|
vi.stubGlobal("fetch", fakeFetch);
|
|
|
|
try {
|
|
const res = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/buckets`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ name: "new-bucket", publicRead: true }),
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const data = (await res.json()) as { name: string; folderId: string; publicRead: boolean };
|
|
expect(data.name).toBe("new-bucket");
|
|
expect(data.folderId).toBe("new-bucket-folder-id");
|
|
expect(data.publicRead).toBe(true);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it("updates bucket publicRead with PATCH /api/buckets/:name", async () => {
|
|
const token = await getValidToken();
|
|
|
|
const fakeFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = new URL(typeof input === "string" ? input : input instanceof Request ? input.url : input.toString());
|
|
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
|
|
if (url.origin === "https://oauth2.googleapis.com") {
|
|
return Response.json({ access_token: "mock-access-token", expires_in: 3600 });
|
|
}
|
|
if (url.pathname === "/drive/v3/files") {
|
|
const q = url.searchParams.get("q") ?? "";
|
|
if (q.includes("name='s3-storage'") && q.includes("'root' in parents")) {
|
|
return Response.json({ files: [{ id: "root-folder-id", name: "s3-storage" }] });
|
|
}
|
|
if (q.includes("'root-folder-id' in parents")) {
|
|
return Response.json({
|
|
files: [{ id: "folder-target-bucket", name: "target-bucket", mimeType: "application/vnd.google-apps.folder", appProperties: { s3PublicRead: "false" } }],
|
|
});
|
|
}
|
|
}
|
|
if (url.pathname === "/drive/v3/files/folder-target-bucket" && method === "PATCH") {
|
|
return Response.json({ id: "folder-target-bucket", name: "target-bucket", appProperties: { s3PublicRead: "true" } });
|
|
}
|
|
return new Response("Not found", { status: 404 });
|
|
});
|
|
|
|
vi.stubGlobal("fetch", fakeFetch);
|
|
|
|
try {
|
|
const res = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/buckets/target-bucket`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ publicRead: true }),
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const data = (await res.json()) as { name: string; publicRead: boolean };
|
|
expect(data.name).toBe("target-bucket");
|
|
expect(data.publicRead).toBe(true);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it("rejects DELETE /api/buckets/:name when bucket has children with 409", async () => {
|
|
const token = await getValidToken();
|
|
|
|
const fakeFetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = new URL(typeof input === "string" ? input : input instanceof Request ? input.url : input.toString());
|
|
|
|
if (url.origin === "https://oauth2.googleapis.com") {
|
|
return Response.json({ access_token: "mock-access-token", expires_in: 3600 });
|
|
}
|
|
if (url.pathname === "/drive/v3/files") {
|
|
const q = url.searchParams.get("q") ?? "";
|
|
if (q.includes("name='s3-storage'") && q.includes("'root' in parents")) {
|
|
return Response.json({ files: [{ id: "root-folder-id", name: "s3-storage" }] });
|
|
}
|
|
if (q.includes("'root-folder-id' in parents")) {
|
|
return Response.json({
|
|
files: [{ id: "folder-non-empty", name: "non-empty", mimeType: "application/vnd.google-apps.folder" }],
|
|
});
|
|
}
|
|
if (q.includes("'folder-non-empty' in parents")) {
|
|
return Response.json({ files: [{ id: "child-file-1" }] });
|
|
}
|
|
}
|
|
return new Response("Not found", { status: 404 });
|
|
});
|
|
|
|
vi.stubGlobal("fetch", fakeFetch);
|
|
|
|
try {
|
|
const res = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/buckets/non-empty`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(res.status).toBe(409);
|
|
const data = (await res.json()) as { message: string };
|
|
expect(data.message).toContain("not empty");
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it("deletes empty bucket with DELETE /api/buckets/:name and returns 204", async () => {
|
|
const token = await getValidToken();
|
|
|
|
const fakeFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = new URL(typeof input === "string" ? input : input instanceof Request ? input.url : input.toString());
|
|
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
|
|
if (url.origin === "https://oauth2.googleapis.com") {
|
|
return Response.json({ access_token: "mock-access-token", expires_in: 3600 });
|
|
}
|
|
if (url.pathname === "/drive/v3/files") {
|
|
const q = url.searchParams.get("q") ?? "";
|
|
if (q.includes("name='s3-storage'") && q.includes("'root' in parents")) {
|
|
return Response.json({ files: [{ id: "root-folder-id", name: "s3-storage" }] });
|
|
}
|
|
if (q.includes("'root-folder-id' in parents")) {
|
|
return Response.json({
|
|
files: [{ id: "folder-empty", name: "empty", mimeType: "application/vnd.google-apps.folder" }],
|
|
});
|
|
}
|
|
if (q.includes("'folder-empty' in parents")) {
|
|
return Response.json({ files: [] });
|
|
}
|
|
}
|
|
if (url.pathname === "/drive/v3/files/folder-empty" && method === "PATCH") {
|
|
return Response.json({ id: "folder-empty", trashed: true });
|
|
}
|
|
return new Response("Not found", { status: 404 });
|
|
});
|
|
|
|
vi.stubGlobal("fetch", fakeFetch);
|
|
|
|
try {
|
|
const res = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/buckets/empty`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(res.status).toBe(204);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it("lists import candidates and imports selected buckets", async () => {
|
|
const token = await getValidToken();
|
|
|
|
const fakeFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = new URL(typeof input === "string" ? input : input instanceof Request ? input.url : input.toString());
|
|
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
|
|
if (url.origin === "https://oauth2.googleapis.com") {
|
|
return Response.json({ access_token: "mock-access-token", expires_in: 3600 });
|
|
}
|
|
if (url.pathname === "/drive/v3/files") {
|
|
const q = url.searchParams.get("q") ?? "";
|
|
if (q.includes("name='s3-storage'") && q.includes("'root' in parents")) {
|
|
return Response.json({ files: [{ id: "root-folder-id", name: "s3-storage" }] });
|
|
}
|
|
if (q.includes("'root-folder-id' in parents")) {
|
|
return Response.json({ files: [] });
|
|
}
|
|
if (q.includes("'root' in parents")) {
|
|
return Response.json({
|
|
files: [
|
|
{ id: "root-folder-id", name: "s3-storage", mimeType: "application/vnd.google-apps.folder" },
|
|
{ id: "import-folder-1", name: "legacy-bucket", mimeType: "application/vnd.google-apps.folder" },
|
|
],
|
|
});
|
|
}
|
|
if (q.includes("'import-folder-1' in parents")) {
|
|
return Response.json({
|
|
files: [
|
|
{ id: "f1", mimeType: "text/plain" },
|
|
{ id: "f2", mimeType: "text/plain" },
|
|
],
|
|
});
|
|
}
|
|
}
|
|
if (url.pathname === "/drive/v3/files/import-folder-1" && method === "PATCH") {
|
|
return Response.json({ id: "import-folder-1", name: "legacy-bucket" });
|
|
}
|
|
return new Response("Not found", { status: 404 });
|
|
});
|
|
|
|
vi.stubGlobal("fetch", fakeFetch);
|
|
|
|
try {
|
|
// GET /api/import-candidates
|
|
const listRes = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/import-candidates`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(listRes.status).toBe(200);
|
|
const listData = (await listRes.json()) as { candidates: Array<{ name: string; folderId: string; objectCount: number }> };
|
|
expect(listData.candidates).toHaveLength(1);
|
|
expect(listData.candidates[0].name).toBe("legacy-bucket");
|
|
expect(listData.candidates[0].objectCount).toBe(2);
|
|
|
|
// POST /api/import
|
|
const importRes = await worker.fetch(
|
|
new Request(`${ENDPOINT}/api/import`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ names: ["legacy-bucket"] }),
|
|
}),
|
|
ENV,
|
|
CTX,
|
|
);
|
|
expect(importRes.status).toBe(200);
|
|
const importData = (await importRes.json()) as { imported: string[]; failed: unknown[] };
|
|
expect(importData.imported).toEqual(["legacy-bucket"]);
|
|
expect(importData.failed).toHaveLength(0);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
});
|