From f377a974b00d515ad81262a42aacef076667fb53 Mon Sep 17 00:00:00 2001 From: nexryai <61890205+nexryai@users.noreply.github.com> Date: Thu, 8 Jan 2026 04:13:18 +0000 Subject: [PATCH] WIP --- src/index.ts | 346 +++++++++++++++++++++++++++++++++++++++++------- test/s3.test.ts | 236 ++++++++++++++++++++++++++++----- wrangler.jsonc | 14 +- 3 files changed, 515 insertions(+), 81 deletions(-) diff --git a/src/index.ts b/src/index.ts index dca941a..2c31a2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,11 @@ /** * S3-Compatible API Server on Cloudflare Workers - * Supports both Header-based and Presigned URL authentication + * Backend: Google Drive with streaming support */ export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const start = performance.now(); try { const isValid = await verifySignature(request, env); if (!isValid) { @@ -14,21 +15,73 @@ export default { const url = new URL(request.url); const method = request.method; - // ここでバックエンドストレージとの接続処理を実装 - // 例: R2, KV, または外部ストレージへのプロキシ + // パスからバケット名とオブジェクトキーを抽出 + const pathParts = url.pathname.split("/").filter((p) => p); + const bucket = pathParts[0] || ""; + const objectKey = pathParts.slice(1).join("/"); + + const accessToken = await getAccessToken(env); if (method === "PUT" || method === "POST") { - // ストリーミングアップロード処理 - // await uploadToBackend(request.body, url.pathname); - return new Response(`Verified ${method} for ${url.pathname}`, { status: 200 }); + // ストリーミングアップロード + if (!objectKey) { + return new Response("Object key required", { status: 400 }); + } + + const contentType = request.headers.get("Content-Type") || "application/octet-stream"; + const result = await streamUploadToDrive(accessToken, request.body, bucket, objectKey, contentType, env); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { + "Content-Type": "application/json", + ETag: `"${result.id}"`, // Google DriveのファイルIDをETagとして使用 + }, + }); } else if (method === "GET") { - // ストリーミングダウンロード処理 - // const stream = await downloadFromBackend(url.pathname); - // return new Response(stream, { status: 200 }); - return new Response(`Verified ${method} for ${url.pathname}`, { status: 200 }); + // ストリーミングダウンロード + if (!objectKey) { + // バケット(フォルダ)の一覧を返す + const files = await listFiles(accessToken, bucket, env); + return new Response(generateListBucketResult(files, bucket), { + status: 200, + headers: { "Content-Type": "application/xml" }, + }); + } + + const fileStream = await streamDownloadFromDrive(accessToken, bucket, objectKey, env); + + return new Response(fileStream.body, { + status: 200, + headers: { + "Content-Type": fileStream.contentType, + "Content-Length": fileStream.size.toString(), + ETag: `"${fileStream.id}"`, + }, + }); } else if (method === "DELETE") { - // 204 No Content はボディを持てない + // ファイル削除 + if (!objectKey) { + return new Response("Object key required", { status: 400 }); + } + + await deleteFromDrive(accessToken, bucket, objectKey, env); return new Response(null, { status: 204 }); + } else if (method === "HEAD") { + // メタデータ取得 + if (!objectKey) { + return new Response(null, { status: 400 }); + } + + const metadata = await getFileMetadata(accessToken, bucket, objectKey, env); + return new Response(null, { + status: 200, + headers: { + "Content-Type": metadata.mimeType, + "Content-Length": metadata.size.toString(), + ETag: `"${metadata.id}"`, + }, + }); } return new Response("Method not allowed", { status: 405 }); @@ -36,6 +89,11 @@ export default { const error = e as Error; console.error("Error:", error); return new Response(error.message, { status: 500 }); + } finally { + const end = performance.now(); + const cpuUsed = end - start; + + console.log(`CPU time used: ${cpuUsed}ms`); } }, } satisfies ExportedHandler; @@ -44,14 +102,242 @@ interface Env { ACCESS_KEY: string; SECRET_KEY: string; REGION: string; + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; + GOOGLE_REFRESH_TOKEN: string; + AUTH_KV: KVNamespace; + FOLDER_CACHE: KVNamespace; // バケット名→フォルダIDのキャッシュ } +// ======================================== +// Google Drive API Functions +// ======================================== + +async function getAccessToken(env: Env): Promise { + const cacheKey = "google_access_token"; + + const cachedToken = await env.AUTH_KV.get(cacheKey); + if (cachedToken) { + return cachedToken; + } + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + refresh_token: env.GOOGLE_REFRESH_TOKEN, + grant_type: "refresh_token", + }), + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(`Token Error: ${data.error_description}`); + } + + await env.AUTH_KV.put(cacheKey, data.access_token, { + expirationTtl: data.expires_in - 60, + }); + + return data.access_token; +} + +async function getOrCreateFolder(accessToken: string, folderName: string, env: Env): Promise { + // キャッシュを確認 + const cached = await env.FOLDER_CACHE.get(folderName); + if (cached) return cached; + + // フォルダを検索 + const searchRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=name='${encodeURIComponent(folderName)}' and mimeType='application/vnd.google-apps.folder' and trashed=false`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + const searchData = await searchRes.json(); + + if (searchData.files && searchData.files.length > 0) { + const folderId = searchData.files[0].id; + await env.FOLDER_CACHE.put(folderName, folderId, { expirationTtl: 3600 }); + return folderId; + } + + // フォルダが存在しない場合は作成 + const createRes = await fetch("https://www.googleapis.com/drive/v3/files", { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: folderName, + mimeType: "application/vnd.google-apps.folder", + }), + }); + + const createData = await createRes.json(); + await env.FOLDER_CACHE.put(folderName, createData.id, { expirationTtl: 3600 }); + return createData.id; +} + +async function streamUploadToDrive(accessToken: string, stream: ReadableStream | null, bucket: string, objectKey: string, mimeType: string, env: Env): Promise { + if (!stream) { + throw new Error("Request body is required"); + } + + // バケット名に対応するフォルダIDを取得 + const folderId = await getOrCreateFolder(accessToken, bucket, env); + + // Resumable uploadの初期化 + const initRes = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable", { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "X-Upload-Content-Type": mimeType, + "Content-Type": "application/json; charset=UTF-8", + }, + body: JSON.stringify({ + name: objectKey, + parents: [folderId], + }), + }); + + const uploadUrl = initRes.headers.get("Location"); + if (!uploadUrl) { + throw new Error("Failed to get upload URL"); + } + + // ストリーミングアップロード + const uploadRes = await fetch(uploadUrl, { + method: "PUT", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: stream, + duplex: "half" as any, + }); + + if (!uploadRes.ok) { + const errorText = await uploadRes.text(); + throw new Error(`Upload failed: ${errorText}`); + } + + return await uploadRes.json(); +} + +async function findFileInFolder(accessToken: string, folderId: string, fileName: string): Promise { + const searchRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=name='${encodeURIComponent(fileName)}' and '${folderId}' in parents and trashed=false&fields=files(id,name,mimeType,size)`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + const data = await searchRes.json(); + return data.files && data.files.length > 0 ? data.files[0] : null; +} + +async function streamDownloadFromDrive(accessToken: string, bucket: string, objectKey: string, env: Env): Promise<{ body: ReadableStream; contentType: string; size: number; id: string }> { + const folderId = await getOrCreateFolder(accessToken, bucket, env); + const file = await findFileInFolder(accessToken, folderId, objectKey); + + if (!file) { + throw new Error("File not found"); + } + + const downloadRes = await fetch(`https://www.googleapis.com/drive/v3/files/${file.id}?alt=media`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!downloadRes.ok) { + throw new Error("Download failed"); + } + + return { + body: downloadRes.body!, + contentType: file.mimeType || "application/octet-stream", + size: parseInt(file.size || "0"), + id: file.id, + }; +} + +async function deleteFromDrive(accessToken: string, bucket: string, objectKey: string, env: Env): Promise { + const folderId = await getOrCreateFolder(accessToken, bucket, env); + const file = await findFileInFolder(accessToken, folderId, objectKey); + + if (!file) { + throw new Error("File not found"); + } + + const deleteRes = await fetch(`https://www.googleapis.com/drive/v3/files/${file.id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!deleteRes.ok) { + throw new Error("Delete failed"); + } +} + +async function getFileMetadata(accessToken: string, bucket: string, objectKey: string, env: Env): Promise<{ id: string; mimeType: string; size: number }> { + const folderId = await getOrCreateFolder(accessToken, bucket, env); + const file = await findFileInFolder(accessToken, folderId, objectKey); + + if (!file) { + throw new Error("File not found"); + } + + return { + id: file.id, + mimeType: file.mimeType || "application/octet-stream", + size: parseInt(file.size || "0"), + }; +} + +async function listFiles(accessToken: string, bucket: string, env: Env): Promise { + const folderId = await getOrCreateFolder(accessToken, bucket, env); + + const listRes = await fetch(`https://www.googleapis.com/drive/v3/files?q='${folderId}' in parents and trashed=false&fields=files(id,name,mimeType,size,modifiedTime)`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + const data = await listRes.json(); + return data.files || []; +} + +function generateListBucketResult(files: any[], bucket: string): string { + const contents = files + .map( + (f) => ` + + ${escapeXml(f.name)} + ${f.modifiedTime || new Date().toISOString()} + "${f.id}" + ${f.size || 0} + STANDARD + `, + ) + .join(""); + + return ` + + ${escapeXml(bucket)} + + 1000 + false + ${contents} +`; +} + +function escapeXml(str: string): string { + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + +// ======================================== +// AWS Signature V4 Verification +// ======================================== + async function verifySignature(request: Request, env: Env): Promise { - const start = performance.now(); const url = new URL(request.url); const headers = request.headers; - // Query-based auth (Presigned URL) かどうか判定 const isQueryAuth = url.searchParams.has("X-Amz-Algorithm"); let algorithm: string; @@ -66,27 +352,22 @@ async function verifySignature(request: Request, env: Env): Promise { return false; } - // 日時情報の取得 const datetime = (isQueryAuth ? url.searchParams.get("X-Amz-Date") : headers.get("x-amz-date")) ?? ""; if (!datetime) return false; const date = datetime.substring(0, 8); - // Canonical Request の生成 const canonicalRequest = await createCanonicalRequest(request, isQueryAuth); const hashedCanonicalRequest = await sha256(canonicalRequest); - // String to Sign の生成 const credentialScope = `${date}/${env.REGION}/s3/aws4_request`; const stringToSign = ["AWS4-HMAC-SHA256", datetime, credentialScope, hashedCanonicalRequest].join("\n"); - // 署名の計算 const signingKey = await getSigningKey(env.SECRET_KEY, date, env.REGION, "s3"); const signature = await hmacSha256(signingKey, stringToSign); const signatureHex = bufToHex(signature); - // 期待される署名の取得 let expectedSignature = ""; if (isQueryAuth) { expectedSignature = url.searchParams.get("X-Amz-Signature") ?? ""; @@ -96,38 +377,18 @@ async function verifySignature(request: Request, env: Env): Promise { expectedSignature = match ? match[1] : ""; } - // デバッグ用 - if (signatureHex !== expectedSignature) { - console.log("Signature mismatch!"); - console.log("Canonical Request:", canonicalRequest); - console.log("String to Sign:", stringToSign); - console.log("Computed:", signatureHex); - console.log("Expected:", expectedSignature); - } - - const end = performance.now(); - const cpuUsed = end - start; - - console.debug(`CPU time used: ${cpuUsed}ms`); - return signatureHex === expectedSignature; } async function createCanonicalRequest(request: Request, isQueryAuth: boolean): Promise { const url = new URL(request.url); - // HTTPメソッド const method = request.method; - - // Canonical URI (パス部分) const canonicalUri = url.pathname || "/"; - // Canonical Query String - // AWS署名バージョン4では、パラメータ名の大文字小文字を区別してソート const params = Array.from(url.searchParams.entries()) - .filter(([key]) => key !== "X-Amz-Signature") // 署名自体は除外 + .filter(([key]) => key !== "X-Amz-Signature") .sort(([a], [b]) => { - // バイナリソート(大文字小文字を区別) if (a < b) return -1; if (a > b) return 1; return 0; @@ -135,7 +396,6 @@ async function createCanonicalRequest(request: Request, isQueryAuth: boolean): P .map(([key, val]) => `${encodeRFC3986(key)}=${encodeRFC3986(val)}`) .join("&"); - // Signed Headers の取得 let signedHeadersList: string[]; if (isQueryAuth) { signedHeadersList = (url.searchParams.get("X-Amz-SignedHeaders") ?? "host").split(";"); @@ -145,16 +405,13 @@ async function createCanonicalRequest(request: Request, isQueryAuth: boolean): P signedHeadersList = match ? match[1].split(";") : ["host"]; } - // Canonical Headers の生成 const canonicalHeaders = signedHeadersList .map((h) => { const headerName = h.toLowerCase(); let headerValue = ""; if (headerName === "host") { - // ホストヘッダーをそのまま使用(URLのhostnameを使用) headerValue = url.hostname; - // 非標準ポートの場合はポート番号を追加 const port = url.port; if (port && !((url.protocol === "https:" && port === "443") || (url.protocol === "http:" && port === "80"))) { headerValue += `:${port}`; @@ -168,14 +425,11 @@ async function createCanonicalRequest(request: Request, isQueryAuth: boolean): P .join(""); const signedHeaders = signedHeadersList.join(";"); - - // Payload Hash const payloadHash = request.headers.get("x-amz-content-sha256") ?? (isQueryAuth ? "UNSIGNED-PAYLOAD" : "UNSIGNED-PAYLOAD"); return [method, canonicalUri, params, canonicalHeaders, signedHeaders, payloadHash].join("\n"); } -// RFC3986に準拠したURLエンコーディング function encodeRFC3986(str: string): string { return encodeURIComponent(str).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); } diff --git a/test/s3.test.ts b/test/s3.test.ts index 5bff9dd..d9ac155 100644 --- a/test/s3.test.ts +++ b/test/s3.test.ts @@ -1,13 +1,19 @@ -import { describe, it, expect, vi } from "vitest"; -import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { AwsClient } from "aws4fetch"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + import worker from "../src/index"; const ENV = { ACCESS_KEY: "test-access-key", SECRET_KEY: "test-secret-key", REGION: "auto", + GOOGLE_CLIENT_ID: "test-client-id", + GOOGLE_CLIENT_SECRET: "test-client-secret", + GOOGLE_REFRESH_TOKEN: "test-refresh-token", + AUTH_KV: createMockKV(), + FOLDER_CACHE: createMockKV(), }; const CTX = { @@ -15,10 +21,105 @@ const CTX = { passThroughOnException: vi.fn(), }; -describe("S3 API Server Authentication", () => { +function createMockKV() { + const storage = new Map(); + return { + get: vi.fn(async (key: string) => storage.get(key) || null), + put: vi.fn(async (key: string, value: string, options?: any) => { + storage.set(key, value); + }), + delete: vi.fn(async (key: string) => storage.delete(key)), + list: vi.fn(async () => ({ keys: [] })), + }; +} + +// Google API のモック +global.fetch = vi.fn(async (url: string | URL | Request, init?: any) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; + + // OAuth トークン取得 + if (urlStr.includes("oauth2.googleapis.com/token")) { + return new Response( + JSON.stringify({ + access_token: "mock-access-token", + expires_in: 3600, + }), + { status: 200 }, + ); + } + + // フォルダ検索 + if (urlStr.includes("drive/v3/files") && urlStr.includes("mimeType='application/vnd.google-apps.folder'")) { + return new Response( + JSON.stringify({ + files: [{ id: "folder-id-123", name: "test-bucket" }], + }), + { status: 200 }, + ); + } + + // ファイル検索 + if (urlStr.includes("drive/v3/files") && urlStr.includes("in parents")) { + return new Response( + JSON.stringify({ + files: [ + { + id: "file-id-456", + name: "test-file.txt", + mimeType: "text/plain", + size: "11", + }, + ], + }), + { status: 200 }, + ); + } + + // Resumable upload 初期化 + if (urlStr.includes("uploadType=resumable") && init?.method === "POST") { + return new Response(null, { + status: 200, + headers: { Location: "https://www.googleapis.com/upload/drive/v3/files/uploadid123" }, + }); + } + + // Resumable upload 実行 + if (urlStr.includes("upload/drive/v3/files/uploadid")) { + return new Response( + JSON.stringify({ + id: "new-file-id-789", + name: "uploaded-file.txt", + mimeType: "text/plain", + }), + { status: 200 }, + ); + } + + // ファイルダウンロード + if (urlStr.includes("alt=media")) { + return new Response("Hello World", { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); + } + + // ファイル削除 + if (init?.method === "DELETE") { + return new Response(null, { status: 204 }); + } + + return new Response("Not Found", { status: 404 }); +}) as any; + +describe("S3 API Server with Google Drive Backend", () => { const endpoint = "https://s3-api.example.com"; - it("should verify requests from aws4fetch (Header Auth)", async () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // 1. PUT (Upload) テスト + it("should upload file via PUT with Header Auth", async () => { const aws4 = new AwsClient({ accessKeyId: ENV.ACCESS_KEY, secretAccessKey: ENV.SECRET_KEY, @@ -33,14 +134,83 @@ describe("S3 API Server Authentication", () => { "Content-Type": "text/plain", "x-amz-content-sha256": "UNSIGNED-PAYLOAD", }, - body: "hello world", + body: "Hello World", }); const response = await worker.fetch(signedReq, ENV, CTX); expect(response.status).toBe(200); - expect(await response.text()).toContain("Verified PUT"); + + const result = await response.json(); + expect(result).toHaveProperty("id"); }); + // 2. GET (Download) テスト + it("should download file via GET with Header Auth", async () => { + const aws4 = new AwsClient({ + accessKeyId: ENV.ACCESS_KEY, + secretAccessKey: ENV.SECRET_KEY, + region: ENV.REGION, + service: "s3", + }); + + const requestUrl = `${endpoint}/test-bucket/test-file.txt`; + const signedReq = await aws4.sign(requestUrl, { + method: "GET", + headers: { + "x-amz-content-sha256": "UNSIGNED-PAYLOAD", + }, + }); + + const response = await worker.fetch(signedReq, ENV, CTX); + expect(response.status).toBe(200); + expect(await response.text()).toBe("Hello World"); + }); + + // 3. DELETE テスト + it("should delete file via DELETE", async () => { + const aws4 = new AwsClient({ + accessKeyId: ENV.ACCESS_KEY, + secretAccessKey: ENV.SECRET_KEY, + region: ENV.REGION, + service: "s3", + }); + + const requestUrl = `${endpoint}/test-bucket/test-file.txt`; + const signedReq = await aws4.sign(requestUrl, { + method: "DELETE", + headers: { + "x-amz-content-sha256": "UNSIGNED-PAYLOAD", + }, + }); + + const response = await worker.fetch(signedReq, ENV, CTX); + expect(response.status).toBe(204); + }); + + // 4. HEAD (Metadata) テスト + it("should get file metadata via HEAD", async () => { + const aws4 = new AwsClient({ + accessKeyId: ENV.ACCESS_KEY, + secretAccessKey: ENV.SECRET_KEY, + region: ENV.REGION, + service: "s3", + }); + + const requestUrl = `${endpoint}/test-bucket/test-file.txt`; + const signedReq = await aws4.sign(requestUrl, { + method: "HEAD", + headers: { + "x-amz-content-sha256": "UNSIGNED-PAYLOAD", + }, + }); + + const response = await worker.fetch(signedReq, ENV, CTX); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/plain"); + expect(response.headers.get("Content-Length")).toBe("11"); + }); + + // 5. Presigned URL テスト it("should verify presigned URLs from @aws-sdk/s3-request-presigner", async () => { const s3 = new S3Client({ region: ENV.REGION, @@ -56,9 +226,6 @@ describe("S3 API Server Authentication", () => { }); const url = await getSignedUrl(s3, command, { expiresIn: 3600 }); - - console.log("Presigned URL:", url); - const parsedUrl = new URL(url); const testUrl = new URL(endpoint); @@ -70,27 +237,9 @@ describe("S3 API Server Authentication", () => { const response = await worker.fetch(request, ENV, CTX); expect(response.status).toBe(200); - expect(await response.text()).toContain("Verified GET"); - }); - - it("should verify requests from aws4fetch with query parameters", async () => { - const aws4 = new AwsClient({ - accessKeyId: ENV.ACCESS_KEY, - secretAccessKey: ENV.SECRET_KEY, - region: ENV.REGION, - service: "s3", - }); - - const requestUrl = `${endpoint}/test-bucket/aws4-fetch-test`; - const signedReq = await aws4.sign(requestUrl, { - method: "GET", - aws: { signQuery: true }, // クエリパラメータ署名 - }); - - const response = await worker.fetch(signedReq, ENV, CTX); - expect(response.status).toBe(200); }); + // 6. 不正な署名のテスト it("should reject invalid signatures", async () => { const request = new Request(`${endpoint}/hack`, { method: "GET", @@ -104,8 +253,8 @@ describe("S3 API Server Authentication", () => { expect(response.status).toBe(403); }); - // 5. DELETE メソッドのテスト - it("should verify DELETE requests", async () => { + // 7. バケット一覧テスト (LIST) + it("should list files in bucket", async () => { const aws4 = new AwsClient({ accessKeyId: ENV.ACCESS_KEY, secretAccessKey: ENV.SECRET_KEY, @@ -113,15 +262,36 @@ describe("S3 API Server Authentication", () => { service: "s3", }); - const requestUrl = `${endpoint}/test-bucket/file-to-delete.txt`; + // ファイル一覧を返すようにモックを更新 + (global.fetch as any).mockImplementationOnce(async (url: string) => { + if (url.includes("drive/v3/files") && url.includes("in parents")) { + return new Response( + JSON.stringify({ + files: [ + { id: "1", name: "file1.txt", size: "100", modifiedTime: "2024-01-01T00:00:00Z" }, + { id: "2", name: "file2.txt", size: "200", modifiedTime: "2024-01-02T00:00:00Z" }, + ], + }), + { status: 200 }, + ); + } + return new Response("Not Found", { status: 404 }); + }); + + const requestUrl = `${endpoint}/test-bucket/`; const signedReq = await aws4.sign(requestUrl, { - method: "DELETE", + method: "GET", headers: { "x-amz-content-sha256": "UNSIGNED-PAYLOAD", }, }); const response = await worker.fetch(signedReq, ENV, CTX); - expect(response.status).toBe(204); + expect(response.status).toBe(200); + + const xmlText = await response.text(); + expect(xmlText).toContain("