mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 08:22:24 +00:00
Two gaps where generic S3 trailed Cloudflare R2: 1. Object cleanup on delete/edit didn't work for generic S3. deleteVideoStorage built its client via makeR2Client, which hard-forces path-style and ignores the connection's forcePathStyle -- so vhost-style S3 deletes were misaddressed and cleanup silently failed (S3 users' deleted/edited files orphaned). It is now provider-aware (mirrors uploadFile): R2 -> makeR2Client (path-style), generic S3 -> makeS3Client honoring forcePathStyle. makeS3Client is now injectable for testing. 2. CORS guidance on upload failure was R2-only. Extracted isLikelyCorsError + a generic-S3 guidance builder into js/utils/uploadErrorHints.js (re-exported from r2Service for back-compat); s3UploadService now appends actionable CORS guidance on an opaque "Failed to fetch" too. Generic S3 is now at functional parity with R2 for upload -> publish -> cleanup. Tests: r2-delete-video-storage (adds generic-S3 forcePathStyle routing), s3-upload-cors-guidance (new). Full suite green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
132 lines
4.5 KiB
JavaScript
132 lines
4.5 KiB
JavaScript
// Scenario (SCN-delete-video-storage):
|
|
// When a video is deleted, its R2/S3 objects must be removed so the file isn't
|
|
// left publicly downloadable (Cloudflare R2 cleanup). The method must be
|
|
// best-effort: delete owned objects, skip when storage is locked / has no
|
|
// creds, never touch external URLs, and never throw (a failed object delete is
|
|
// recorded, not propagated).
|
|
//
|
|
// Anti-cheat: drives the real deleteVideoStorage with I/O stubbed (resolveConnection,
|
|
// makeR2Client, deleteObject) and asserts which keys were actually deleted.
|
|
|
|
import "../test-helpers/setup-localstorage.mjs";
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
// Disable the AWS SDK network import before the storage modules load.
|
|
globalThis.__BITVID_DISABLE_NETWORK_IMPORTS__ = true;
|
|
const { R2Service } = await import("../../js/services/r2Service.js");
|
|
|
|
const BASE = "https://pub.bitvid.network";
|
|
const CREDS = {
|
|
provider: "cloudflare_r2",
|
|
accountId: "acct",
|
|
accessKeyId: "AK",
|
|
secretAccessKey: "SK",
|
|
bucket: "my-bucket",
|
|
publicBaseUrl: BASE,
|
|
endpoint: "https://acct.r2.cloudflarestorage.com",
|
|
region: "auto",
|
|
};
|
|
|
|
function buildService(creds, deleteImpl) {
|
|
const deleted = [];
|
|
const clients = []; // records which client builder was used (r2 vs s3) + args
|
|
const svc = new R2Service({
|
|
makeR2Client: (args) => {
|
|
clients.push({ kind: "r2", args });
|
|
return { __mock: "r2" };
|
|
},
|
|
makeS3Client: (args) => {
|
|
clients.push({ kind: "s3", args });
|
|
return { __mock: "s3" };
|
|
},
|
|
deleteObject:
|
|
deleteImpl ||
|
|
(async ({ key }) => {
|
|
deleted.push(key);
|
|
}),
|
|
});
|
|
svc.resolveConnection = async () => creds;
|
|
return { svc, deleted, clients };
|
|
}
|
|
|
|
test("deletes the video, its .torrent, and the thumbnail for an owned video (R2)", async () => {
|
|
const { svc, deleted, clients } = buildService(CREDS);
|
|
const res = await svc.deleteVideoStorage({
|
|
npub: "npub1owner",
|
|
videos: [
|
|
{ url: `${BASE}/u/np/h/clip.mp4`, thumbnail: `${BASE}/u/np/h/clip.thumb.jpg` },
|
|
],
|
|
});
|
|
assert.equal(res.skipped, false);
|
|
assert.deepEqual(
|
|
new Set(deleted),
|
|
new Set(["u/np/h/clip.mp4", "u/np/h/clip.torrent", "u/np/h/clip.thumb.jpg"]),
|
|
);
|
|
assert.equal(res.deleted.length, 3);
|
|
// R2 connection => R2 (path-style) client.
|
|
assert.equal(clients[0]?.kind, "r2");
|
|
});
|
|
|
|
test("uses a forcePathStyle-aware S3 client for a generic-S3 connection", async () => {
|
|
// No accountId, non-R2 provider, virtual-hosted-style bucket.
|
|
const s3Creds = {
|
|
provider: "s3",
|
|
accessKeyId: "AK",
|
|
secretAccessKey: "SK",
|
|
bucket: "my-bucket",
|
|
publicBaseUrl: BASE,
|
|
endpoint: "https://s3.us-east-1.amazonaws.com",
|
|
region: "us-east-1",
|
|
forcePathStyle: false,
|
|
};
|
|
const { svc, deleted, clients } = buildService(s3Creds);
|
|
const res = await svc.deleteVideoStorage({
|
|
npub: "npub1owner",
|
|
videos: [{ url: `${BASE}/u/np/h/clip.mp4` }],
|
|
});
|
|
assert.equal(res.skipped, false);
|
|
assert.ok(deleted.includes("u/np/h/clip.mp4"), "deletes the S3 object");
|
|
// Generic S3 => S3 client built with the connection's forcePathStyle (not forced).
|
|
assert.equal(clients[0]?.kind, "s3");
|
|
assert.equal(clients[0]?.args.forcePathStyle, false);
|
|
assert.equal(clients[0]?.args.endpoint, "https://s3.us-east-1.amazonaws.com");
|
|
});
|
|
|
|
test("skips entirely when storage is locked (no credentials available)", async () => {
|
|
const { svc, deleted } = buildService({ ...CREDS, accessKeyId: "", secretAccessKey: "" });
|
|
const res = await svc.deleteVideoStorage({
|
|
npub: "npub1owner",
|
|
videos: [{ url: `${BASE}/u/np/h/clip.mp4` }],
|
|
});
|
|
assert.equal(res.skipped, true);
|
|
assert.equal(res.reason, "storage-locked");
|
|
assert.equal(deleted.length, 0);
|
|
});
|
|
|
|
test("never deletes external/3rd-party objects", async () => {
|
|
const { svc, deleted } = buildService(CREDS);
|
|
const res = await svc.deleteVideoStorage({
|
|
npub: "npub1owner",
|
|
videos: [{ url: "https://youtube.com/watch?v=x" }],
|
|
});
|
|
assert.equal(deleted.length, 0);
|
|
assert.equal(res.skipped, true);
|
|
assert.equal(res.reason, "no-matching-objects");
|
|
});
|
|
|
|
test("records a failed object delete without throwing", async () => {
|
|
const deleted = [];
|
|
const { svc } = buildService(CREDS, async ({ key }) => {
|
|
if (key.endsWith(".torrent")) throw new Error("boom");
|
|
deleted.push(key);
|
|
});
|
|
const res = await svc.deleteVideoStorage({
|
|
npub: "npub1owner",
|
|
videos: [{ url: `${BASE}/u/np/h/clip.mp4` }],
|
|
});
|
|
assert.equal(res.failed.length, 1);
|
|
assert.ok(res.failed[0].key.endsWith(".torrent"));
|
|
assert.ok(res.deleted.includes("u/np/h/clip.mp4"));
|
|
});
|