diff --git a/js/services/r2Service.js b/js/services/r2Service.js index 45fbf5ec..7b87ecef 100644 --- a/js/services/r2Service.js +++ b/js/services/r2Service.js @@ -114,9 +114,20 @@ function safeDecodeNpub(npub) { } class R2Service { - constructor() { + constructor({ + makeR2Client: makeR2ClientOverride, + multipartUpload: multipartUploadOverride, + ensureBucketExists: ensureBucketExistsOverride, + ensureBucketCors: ensureBucketCorsOverride, + deleteObject: deleteObjectOverride, + } = {}) { this.listeners = new Map(); this.cloudflareSettings = null; + this.makeR2Client = makeR2ClientOverride || makeR2Client; + this.multipartUpload = multipartUploadOverride || multipartUpload; + this.ensureBucketExists = ensureBucketExistsOverride || ensureBucketExists; + this.ensureBucketCors = ensureBucketCorsOverride || ensureBucketCors; + this.deleteObject = deleteObjectOverride || deleteObject; } on(event, handler) { @@ -481,7 +492,7 @@ class R2Service { // We attempt to ensure the bucket exists and CORS is set up using the S3 keys if possible. if (accessKeyId && secretAccessKey) { try { - const s3 = makeR2Client({ + const s3 = this.makeR2Client({ accountId, accessKeyId, secretAccessKey, @@ -491,7 +502,7 @@ class R2Service { // Attempt to auto-create the bucket (requires Admin keys, but harmless if fails) try { - await ensureBucketExists({ + await this.ensureBucketExists({ s3, bucket: bucketName, region: settings.region, @@ -506,7 +517,7 @@ class R2Service { } if (corsOrigins.length > 0) { - await ensureBucketCors({ + await this.ensureBucketCors({ s3, bucket: bucketName, origins: corsOrigins, @@ -586,7 +597,7 @@ class R2Service { try { // 1. Initialize S3 - const s3 = makeR2Client({ + const s3 = this.makeR2Client({ accountId, accessKeyId, secretAccessKey, @@ -596,7 +607,7 @@ class R2Service { // 2. Ensure bucket (best effort) try { - await ensureBucketExists({ + await this.ensureBucketExists({ s3, bucket: bucketName, region: settings.region, @@ -610,7 +621,7 @@ class R2Service { try { const corsOrigins = this.getCorsOrigins(); if (corsOrigins.length > 0) { - await ensureBucketCors({ + await this.ensureBucketCors({ s3, bucket: bucketName, origins: corsOrigins, @@ -623,7 +634,7 @@ class R2Service { // 4. Upload Test File const file = new File([verifyContent], "verify.txt", { type: "text/plain" }); - await multipartUpload({ + await this.multipartUpload({ s3, bucket: bucketName, key: verifyKey, @@ -641,7 +652,7 @@ class R2Service { if (!response.ok) { // Cleanup attempt - try { await deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {} + try { await this.deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {} if (response.status === 404) { return { success: false, error: "File not found. Check your Public Bucket URL." }; @@ -652,7 +663,7 @@ class R2Service { const text = await response.text(); // Cleanup - try { await deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {} + try { await this.deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {} if (text.trim() !== verifyContent) { return { success: false, error: "Content mismatch. URL might be pointing elsewhere." }; @@ -881,7 +892,7 @@ class R2Service { }; try { - const s3 = makeR2Client({ + const s3 = this.makeR2Client({ accountId, accessKeyId, secretAccessKey, @@ -895,7 +906,7 @@ class R2Service { const thumbKey = key.replace(/\.[^/.]+$/, "") + `.thumb.${thumbExt}`; try { - await multipartUpload({ + await this.multipartUpload({ s3, bucket: bucketEntry.bucket, key: thumbKey, @@ -915,7 +926,7 @@ class R2Service { this.setCloudflareUploadStatus(statusMessage, "info"); - await multipartUpload({ + await this.multipartUpload({ s3, bucket: bucketEntry.bucket, key, @@ -935,7 +946,7 @@ class R2Service { this.setCloudflareUploadStatus("Uploading torrent metadata...", "info"); const torrentKey = buildTorrentKey(); try { - await multipartUpload({ + await this.multipartUpload({ s3, bucket: bucketEntry.bucket, key: torrentKey, @@ -1101,7 +1112,7 @@ class R2Service { const useR2 = provider === "cloudflare_r2" || Boolean(accountId); if (useR2) { - s3 = makeR2Client({ + s3 = this.makeR2Client({ accountId, accessKeyId, secretAccessKey, @@ -1119,7 +1130,7 @@ class R2Service { }); } - await multipartUpload({ + await this.multipartUpload({ s3, bucket, key, diff --git a/tests/unit/services/r2Service.storage-config.test.mjs b/tests/unit/services/r2Service.storage-config.test.mjs new file mode 100644 index 00000000..b4017d63 --- /dev/null +++ b/tests/unit/services/r2Service.storage-config.test.mjs @@ -0,0 +1,158 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import r2ServiceDefault from "../../../js/services/r2Service.js"; +import storageService from "../../../js/services/storageService.js"; +import { sanitizeBucketName } from "../../../js/storage/r2-mgmt.js"; + +const R2Service = r2ServiceDefault.constructor; +const npub = "npub1r2buckettest"; +const metaBucket = "r2-meta-bucket"; +const sanitizedBucket = sanitizeBucketName(npub); + +let originalWindow; +let originalFile; +let originalFetch; +let originalSetTimeout; + +beforeEach(() => { + originalWindow = globalThis.window; + originalFile = globalThis.File; + originalFetch = globalThis.fetch; + originalSetTimeout = globalThis.setTimeout; + + globalThis.window = { + location: { origin: "" }, + NostrTools: { + nip19: { + decode: () => ({ type: "npub", data: "pubkey123" }), + }, + }, + }; + + globalThis.File = class TestFile { + constructor(parts, name, options = {}) { + this.parts = parts; + this.name = name; + this.type = options.type; + } + }; + + globalThis.fetch = async () => ({ + ok: true, + status: 200, + text: async () => "bitvid-verification", + }); + + globalThis.setTimeout = (fn) => { + fn(); + return 0; + }; + + mock.method(storageService, "listConnections", async () => [ + { + id: "conn-1", + provider: "cloudflare_r2", + meta: { defaultForUploads: true }, + }, + ]); + mock.method(storageService, "isUnlocked", () => true); + mock.method(storageService, "getConnection", async () => ({ + provider: "cloudflare_r2", + accountId: "acct-123", + accessKeyId: "access-key", + secretAccessKey: "secret-key", + endpoint: "https://r2.example.com", + region: "auto", + meta: { + bucket: metaBucket, + accountId: "acct-123", + publicBaseUrl: "https://cdn.example.com", + }, + })); + +}); + +afterEach(() => { + mock.restoreAll(); + globalThis.window = originalWindow; + globalThis.File = originalFile; + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; +}); + +describe("R2Service bucket selection", () => { + it("uses meta.bucket for ensureBucketExists and multipartUpload", async () => { + const bucketCalls = { ensure: [], multipart: [] }; + + const ensureBucketExists = mock.fn(async ({ bucket }) => { + bucketCalls.ensure.push(bucket); + }); + const multipartUpload = mock.fn(async ({ bucket }) => { + bucketCalls.multipart.push(bucket); + }); + + const service = new R2Service({ + makeR2Client: mock.fn(() => ({})), + ensureBucketExists, + ensureBucketCors: mock.fn(async () => {}), + multipartUpload, + deleteObject: mock.fn(async () => {}), + }); + + await service.prepareUpload(npub); + + assert.ok(bucketCalls.ensure.length > 0); + for (const bucket of bucketCalls.ensure) { + assert.strictEqual(bucket, metaBucket); + assert.notStrictEqual(bucket, sanitizedBucket); + } + + await service.handleCloudflareUploadSubmit({ + npub, + file: { name: "video.mp4", type: "video/mp4" }, + metadata: { title: "Test upload" }, + publishVideoNote: mock.fn(async () => true), + }); + + assert.ok(bucketCalls.multipart.length > 0); + for (const bucket of bucketCalls.multipart) { + assert.strictEqual(bucket, metaBucket); + assert.notStrictEqual(bucket, sanitizedBucket); + } + }); + + it("uses configured meta.bucket when verifying public access", async () => { + const bucketCalls = { ensure: [], multipart: [] }; + + const ensureBucketExists = mock.fn(async ({ bucket }) => { + bucketCalls.ensure.push(bucket); + }); + const multipartUpload = mock.fn(async ({ bucket }) => { + bucketCalls.multipart.push(bucket); + }); + + const service = new R2Service({ + makeR2Client: mock.fn(() => ({})), + ensureBucketExists, + ensureBucketCors: mock.fn(async () => {}), + multipartUpload, + deleteObject: mock.fn(async () => {}), + }); + const result = await service.verifyPublicAccess({ + npub, + settings: { + accountId: "acct-123", + accessKeyId: "access-key", + secretAccessKey: "secret-key", + baseDomain: "https://cdn.example.com", + meta: { bucket: metaBucket }, + }, + }); + + assert.strictEqual(result.success, true); + assert.ok(bucketCalls.ensure.length > 0); + assert.strictEqual(bucketCalls.ensure[0], metaBucket); + assert.ok(bucketCalls.multipart.length > 0); + assert.strictEqual(bucketCalls.multipart[0], metaBucket); + }); +});