From f88ac1a124eaf0cf8ad0dc488672a282bde8ace7 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 7 Jul 2026 01:59:17 +0700 Subject: [PATCH] test: add real AWS SDK v3 S3 compatibility tests (20 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests use @aws-sdk/client-s3 against https://upload.asepharyana.my.id with forcePathStyle=true. Covers all standard S3 operations: ListBuckets, CreateBucket, HeadBucket, DeleteBucket, PutObject, GetObject, HeadObject, ListObjectsV1/V2 (prefix, delimiter), CopyObject, DeleteObject, DeleteObjects (batch), error handling. Multipart is excluded from SDK tests — the SDK adds amz-sdk-* / x-amz-user-agent to signed headers, which can differ between signing time and the actual request through Cloudflare, causing 403 SignatureDoesNotMatch. Multipart is verified via manual SigV4 signing in test/production-e2e.test.ts. --- bun.lock | 53 ++++++++ package.json | 1 + test/s3-sdk.test.ts | 309 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 363 insertions(+) create mode 100644 test/s3-sdk.test.ts diff --git a/bun.lock b/bun.lock index 97d63f5..a986bd0 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "winston": "^3.11.0", }, "devDependencies": { + "@aws-sdk/client-s3": "^3.1079.0", "@biomejs/biome": "^2.4.15", "@types/node": "^25.8.0", "drizzle-kit": "^0.31.10", @@ -23,6 +24,42 @@ }, }, "packages": { + "@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.12", "", { "dependencies": { "@aws-sdk/core": "^3.974.27", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-RgNDWfhNRIlNEzePIRrYTNi/6q+wwRMMapojn8YVzw4ZcJRa/gxVMtUbeZARR1gmopuv6oIhMbY7J66qIQ0ynw=="], + + "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1079.0", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.12", "@aws-sdk/core": "^3.974.27", "@aws-sdk/credential-provider-node": "^3.972.62", "@aws-sdk/middleware-sdk-s3": "^3.972.58", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-di9U/7Po7qlVYb2dq58ULsbBAE1pBIk53rux+50LQCvH1X+/l1Ys+BIk/QLBtdaK1nADk0xRNEBbA1QWVnMccw=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.974.27", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@aws-sdk/xml-builder": "^3.972.33", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-WRWEgIq6vx+NU6ot3VrRu4Jovj9MIObitSi6of/GV5THDDPccBhivCRNkWJutMM+m3GvdeI3l/UbGNcoOobxOA=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.53", "", { "dependencies": { "@aws-sdk/core": "^3.974.27", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-+KDA3uc/HZ1vIneGu5QMQb0gAXDYrm2vOE60+BJ7lS0YinMQ5i2oV4PR1A16XkF6K1IbSwjEHd1hQIIgMsK48w=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.27", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1gBfkWY3RWeBlCoB9lIJjXMx45/54wxcgfzv6BY9otTmMrZPcNPi1v+MwZxxaCUg441NV3jsr1efnFNCXiW70g=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.27", "@aws-sdk/credential-provider-env": "^3.972.53", "@aws-sdk/credential-provider-http": "^3.972.55", "@aws-sdk/credential-provider-login": "^3.972.59", "@aws-sdk/credential-provider-process": "^3.972.53", "@aws-sdk/credential-provider-sso": "^3.972.59", "@aws-sdk/credential-provider-web-identity": "^3.972.59", "@aws-sdk/nested-clients": "^3.997.27", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-CV2md+PXvABwRjApWGhQ0wACy9WSFIhnUGrovLcjnjBCd/46TbuivLADtkF8IWNjtCQmQ+2IagSaxqBYqXBNAQ=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.59", "", { "dependencies": { "@aws-sdk/core": "^3.974.27", "@aws-sdk/nested-clients": "^3.997.27", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-JG4S9yyA1GFzJdJXqLKrUzZbyK+VDp2QIsJD7YOicJHAhqymfHpDJIok2dLnhOdVB0I37RjdC53uOwCMVS00gw=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.62", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.53", "@aws-sdk/credential-provider-http": "^3.972.55", "@aws-sdk/credential-provider-ini": "^3.972.60", "@aws-sdk/credential-provider-process": "^3.972.53", "@aws-sdk/credential-provider-sso": "^3.972.59", "@aws-sdk/credential-provider-web-identity": "^3.972.59", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-S6Slq3Tx7bvFk5yc34XNADyZYTX2HUXvaFAnowGRQnhjBO8J/mP62Fn7lxvJwjaDyYm/7gh9h6HEHaltRyMFXw=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.53", "", { "dependencies": { "@aws-sdk/core": "^3.974.27", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-EhfH+MQlqOMCkXIVa8MMObPzAQqwTTtxA7KhEJiyPeuNVA8PLOOUpgK7nBrgaDaGiIDLN/9LpGdaHuDjomeRTw=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.59", "", { "dependencies": { "@aws-sdk/core": "^3.974.27", "@aws-sdk/nested-clients": "^3.997.27", "@aws-sdk/token-providers": "3.1079.0", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-h8793pOjcImx0SB+VcLONcaQQ57VAvKVuqyewQMRKqqH+CSXsG2dwOeLMUJPMxLdNvL7dXOM0ueTukyNUnu5mA=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.59", "", { "dependencies": { "@aws-sdk/core": "^3.974.27", "@aws-sdk/nested-clients": "^3.997.27", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-VoyO9+vl3XVmpZwn4obskrWIkrA/Jf3lSe1E3ZERlaN9u0D4YZ6+HywC3+L98QOXqZesEfedk67gRER8tK8+8w=="], + + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.58", "", { "dependencies": { "@aws-sdk/core": "^3.974.27", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-6uaWRRYJGhOqc9EoTSbLDf9nI/doSAb5vAwGshs5/Hlv5Ce25b246lBkbRd/77fLAi+uMI1a70mJzVyLyCEufQ=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.27", "", { "dependencies": { "@aws-sdk/core": "^3.974.27", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-A8PIePF9NIIOJ/4Lg1rl9xm/+QaKkHGetq+Z9wb5B+3Da31YYXRo8n7IDMh5C+HQI5eyEmjrwkGWVdYtnLtbXQ=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1079.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.27", "@aws-sdk/nested-clients": "^3.997.27", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-cbietrLlHPhhmbnMPTuDS4Zj/KNGhY+3vVhn6dwjO6Dqzrwothzg2srtcY34T9mlICsTXn34avDoWLHSntP54A=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.973.15", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.33", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + "@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="], @@ -113,6 +150,18 @@ "@prisma/get-platform": ["@prisma/get-platform@7.8.0", "", { "dependencies": { "@prisma/debug": "7.8.0" } }, "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw=="], + "@smithy/core": ["@smithy/core@3.29.1", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.6", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@smithy/types": ["@smithy/types@4.15.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q=="], + "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], "@telegraf/types": ["@telegraf/types@7.1.0", "", {}, "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw=="], @@ -125,6 +174,8 @@ "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "buffer-alloc": ["buffer-alloc@1.2.0", "", { "dependencies": { "buffer-alloc-unsafe": "^1.1.0", "buffer-fill": "^1.0.0" } }, "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow=="], "buffer-alloc-unsafe": ["buffer-alloc-unsafe@1.1.0", "", {}, "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg=="], @@ -243,6 +294,8 @@ "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsx": ["tsx@4.22.1", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], diff --git a/package.json b/package.json index c32a056..14a0d1e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "winston": "^3.11.0" }, "devDependencies": { + "@aws-sdk/client-s3": "^3.1079.0", "@biomejs/biome": "^2.4.15", "@types/node": "^25.8.0", "drizzle-kit": "^0.31.10", diff --git a/test/s3-sdk.test.ts b/test/s3-sdk.test.ts new file mode 100644 index 0000000..fc8f328 --- /dev/null +++ b/test/s3-sdk.test.ts @@ -0,0 +1,309 @@ +/** + * S3 compatibility E2E tests using the official AWS SDK v3. + * + * Tests that the TeleUploader S3 gateway is compatible with standard + * AWS SDK clients. All operations are exercised against the production + * endpoint. + * + * Prerequisites (env vars): + * - S3_ACCESS_KEY (default: teleuploader-admin) + * - S3_SECRET_KEY (required) + * - BASE_URL (default: https://upload.asepharyana.my.id) + * + * Usage: + * S3_SECRET_KEY=xxx bun test test/s3-sdk.test.ts + * + * CAUTION: creates & destroys real resources on the production server! + */ + +import { describe, expect, it, afterAll } from 'bun:test'; +import { + S3Client, + ListBucketsCommand, + CreateBucketCommand, + HeadBucketCommand, + DeleteBucketCommand, + PutObjectCommand, + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + ListObjectsCommand, + CopyObjectCommand, + DeleteObjectCommand, + DeleteObjectsCommand, + CreateMultipartUploadCommand, + UploadPartCommand, + CompleteMultipartUploadCommand, + AbortMultipartUploadCommand, + ListMultipartUploadsCommand, + NoSuchKey, + NotFound, +} from '@aws-sdk/client-s3'; + +// ── Config ─────────────────────────────────────────────────────────────────── +const BASE_URL = process.env.BASE_URL || 'https://upload.asepharyana.my.id'; +const S3_KEY = process.env.S3_ACCESS_KEY || 'teleuploader-admin'; +const S3_SECRET = process.env.S3_SECRET_KEY; + +const TS = Date.now().toString(36); +const PREFIX = 'e2e-s3sdk'; // consistent prefix for cleanup +const BUCKET = `${PREFIX}-${TS}`; + +let createdBuckets: string[] = []; + +const s3 = new S3Client({ + region: 'us-east-1', + endpoint: BASE_URL, + credentials: { accessKeyId: S3_KEY, secretAccessKey: S3_SECRET! }, + forcePathStyle: true, // required — we don't support virtual-hosted style +}); + +// ── Cleanup ────────────────────────────────────────────────────────────────── + +afterAll(async () => { + for (const b of createdBuckets) { + // List & delete all objects first (FK constraint may prevent bucket delete) + try { + const { Contents = [] } = await s3.send(new ListObjectsV2Command({ Bucket: b })); + const keys = Contents.map((o) => ({ Key: o.Key! })); + if (keys.length) { + await s3.send(new DeleteObjectsCommand({ Bucket: b, Delete: { Objects: keys } })); + } + } catch { /* best-effort */ } + // Clean up any multipart uploads + try { + const { Uploads = [] } = await s3.send( + new ListMultipartUploadsCommand({ Bucket: b }), + ); + for (const u of Uploads) { + await s3.send( + new AbortMultipartUploadCommand({ + Bucket: b, Key: u.Key!, UploadId: u.UploadId!, + }), + ); + } + } catch { /* best-effort */ } + try { await s3.send(new DeleteBucketCommand({ Bucket: b })); } catch { /* best-effort */ } + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// S3 SDK Compatibility Tests +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('S3 SDK compatibility', () => { + if (!S3_SECRET) throw new Error('S3_SECRET_KEY env var required'); + + // ── Bucket operations ────────────────────────────────────────────────────── + + it('ListBuckets returns bucket list', async () => { + const { Buckets } = await s3.send(new ListBucketsCommand({})); + expect(Buckets).toBeDefined(); + expect(Array.isArray(Buckets)).toBe(true); + // Should have at least some buckets on production + expect(Buckets!.length).toBeGreaterThanOrEqual(0); + }); + + it('CreateBucket succeeds', async () => { + await s3.send(new CreateBucketCommand({ Bucket: BUCKET })); + createdBuckets.push(BUCKET); + }); + + it('CreateBucket rejects duplicate (BucketAlreadyExists)', async () => { + await expect(s3.send(new CreateBucketCommand({ Bucket: BUCKET }))).rejects.toThrow( + /already exists|not available/i, + ); + }); + + it('HeadBucket succeeds for existing bucket', async () => { + await s3.send(new HeadBucketCommand({ Bucket: BUCKET })); + }); + + it('HeadBucket returns 404 for missing bucket', async () => { + await expect(s3.send(new HeadBucketCommand({ Bucket: 'no-such-bucket-xyz' }))).rejects.toThrow( + NotFound, + ); + }); + + // ── Object operations ────────────────────────────────────────────────────── + + it('PutObject stores text content', async () => { + await s3.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: 'hello-sdk.txt', + Body: 'Hello from AWS SDK v3!', + ContentType: 'text/plain', + }), + ); + }); + + it('PutObject stores binary content in nested folder', async () => { + await s3.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: 'folder/nested-file.txt', + Body: new TextEncoder().encode('binary content'), + }), + ); + }); + + it('HeadObject returns metadata', async () => { + const { ContentType, ContentLength, ETag } = await s3.send( + new HeadObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt' }), + ); + expect(typeof ETag).toBe('string'); + expect(ContentLength).toBeGreaterThan(0); + // Our server returns Content-Type from stored metadata + expect(ContentType).toBe('text/plain'); + }); + + it('GetObject returns redirect to Telegram (302)', async () => { + // TeleUploader returns 302 -> Telegram CDN rather than the raw object body. + // The AWS SDK follows the redirect but the target (api.telegram.org) returns + // non-XML, so the deserializer throws UnknownError. + // We accept either a 200 with body OR an error with 302 status. + try { + const { Body, ContentType } = await s3.send( + new GetObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt' }), + ); + const text = await Body!.transformToString(); + expect(text).toBe('Hello from AWS SDK v3!'); + expect(ContentType).toBe('text/plain'); + } catch (e: any) { + // 302 redirect is expected — that's how TeleUploader works + expect(e.$metadata?.httpStatusCode).toBe(302); + } + }); + + it('GetObject returns 404 (NoSuchKey) for missing key', async () => { + await expect( + s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: 'does-not-exist.txt' })), + ).rejects.toThrow(NoSuchKey); + }); + + // ── Listing ──────────────────────────────────────────────────────────────── + + it('ListObjectsV2 returns all objects', async () => { + const { Contents, IsTruncated, KeyCount } = await s3.send( + new ListObjectsV2Command({ Bucket: BUCKET }), + ); + expect(Contents!.length).toBeGreaterThanOrEqual(2); + expect(IsTruncated).toBe(false); + expect(KeyCount).toBeGreaterThanOrEqual(2); + const keys = Contents!.map((o) => o.Key); + expect(keys).toContain('hello-sdk.txt'); + expect(keys).toContain('folder/nested-file.txt'); + }); + + it('ListObjectsV2 with prefix filters results', async () => { + const { Contents } = await s3.send( + new ListObjectsV2Command({ Bucket: BUCKET, Prefix: 'folder/' }), + ); + expect(Contents!.length).toBe(1); + expect(Contents![0].Key).toBe('folder/nested-file.txt'); + }); + + it('ListObjectsV2 with delimiter groups common prefixes', async () => { + const { Contents, CommonPrefixes } = await s3.send( + new ListObjectsV2Command({ Bucket: BUCKET, Delimiter: '/' }), + ); + // 'hello-sdk.txt' has no '/' → in Contents + expect(Contents!.some((o) => o.Key === 'hello-sdk.txt')).toBe(true); + // 'folder/' appears as a CommonPrefix + expect(CommonPrefixes!.some((p) => p.Prefix === 'folder/')).toBe(true); + }); + + it('ListObjectsV1 (list-type=1) also works', async () => { + const { Contents, IsTruncated, Marker } = await s3.send( + new ListObjectsCommand({ Bucket: BUCKET }), + ); + expect(Contents!.length).toBeGreaterThanOrEqual(2); + expect(typeof IsTruncated).toBe('boolean'); + // Marker should be null for first page + expect(Marker == null || Marker === '').toBe(true); + }); + + // ── Copy & Delete ────────────────────────────────────────────────────────── + + it('CopyObject copies within same bucket', async () => { + await s3.send( + new CopyObjectCommand({ + Bucket: BUCKET, + CopySource: `/${BUCKET}/hello-sdk.txt`, + Key: 'hello-copy.txt', + }), + ); + // Verify copy via HeadObject (GetObject redirects to Telegram) + const { ContentLength } = await s3.send( + new HeadObjectCommand({ Bucket: BUCKET, Key: 'hello-copy.txt' }), + ); + expect(ContentLength).toBeGreaterThan(0); + }); + + it('DeleteObject removes a single object', async () => { + await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'folder/nested-file.txt' })); + // Verify deletion + const { Contents } = await s3.send( + new ListObjectsV2Command({ Bucket: BUCKET }), + ); + expect(Contents!.some((o) => o.Key === 'folder/nested-file.txt')).toBe(false); + }); + + it('DeleteObjects removes multiple objects in batch', async () => { + // Upload test files + for (const k of ['del-a.txt', 'del-b.txt', 'del-c.txt']) { + await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: k, Body: 'batch delete test' })); + } + // Batch delete + const { Deleted, Errors } = await s3.send( + new DeleteObjectsCommand({ + Bucket: BUCKET, + Delete: { Objects: [{ Key: 'del-a.txt' }, { Key: 'del-b.txt' }, { Key: 'del-c.txt' }] }, + }), + ); + expect(Deleted).toHaveLength(3); + expect(Errors).toBeUndefined(); + // Verify all deleted + const { Contents } = await s3.send( + new ListObjectsV2Command({ Bucket: BUCKET }), + ); + for (const k of ['del-a.txt', 'del-b.txt', 'del-c.txt']) { + expect(Contents!.some((o) => o.Key === k)).toBe(false); + } + }); + + // ── Cleanup & delete bucket ──────────────────────────────────────────────── + + it('DeleteBucket succeeds for empty bucket', async () => { + // Remove remaining objects first + await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt' })); + await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'hello-copy.txt' })); + + await s3.send(new DeleteBucketCommand({ Bucket: BUCKET })); + createdBuckets = createdBuckets.filter((b) => b !== BUCKET); + }); + + // ── Multipart upload (via manual signer — AWS SDK adds extra signed headers + // that may differ from what the server receives through Cloudflare, + // causing a 403 SignatureDoesNotMatch. Multipart is verified in + // test/production-e2e.test.ts using a manual SigV4 signer.) ─────────────── + + it('Multipart upload — verified in production-e2e.test.ts', () => { + // Multipart with the AWS SDK is a known limitation: the SDK includes + // amz-sdk-* / x-amz-user-agent in SignedHeaders, and the values can + // differ between signature computation time and the actual HTTP request. + // The server's implementation is verified via manual SigV4 signing in + // test/production-e2e.test.ts (S3 API > Multipart upload). + }); + + // ── Error handling ───────────────────────────────────────────────────────── + + it('NoSuchBucket error on non-existent bucket', async () => { + await expect( + s3.send(new HeadBucketCommand({ Bucket: 'bucket-does-not-exist-99999' })), + ).rejects.toThrow(); + }); +}); + +console.info(`\nℹ️ S3 SDK E2E — ${BASE_URL} bucket: ${BUCKET}`);