mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
importAccountRecord hard-required encryptedMasterKey, but a Blossom-only account is keyless (no encrypted payloads) and legitimately has none — so its synced record failed to import. Require the envelope ONLY when the record carries an encrypted connection that actually needs it to unlock. Co-Authored-By: Claude Opus 4.8 <[email protected]>
284 lines
12 KiB
JavaScript
284 lines
12 KiB
JavaScript
// Per-provider storage connections: each provider type (R2 / B2 / Custom S3) must keep
|
|
// its OWN credentials so saving one never overwrites another, and the saved set syncs
|
|
// as a whole. The UI used to collapse everything into one shared "default" slot.
|
|
|
|
import { test, describe, beforeEach } from "node:test";
|
|
import assert from "node:assert";
|
|
import * as fakeIndexedDB from "fake-indexeddb";
|
|
|
|
if (!globalThis.indexedDB) {
|
|
globalThis.indexedDB = fakeIndexedDB.indexedDB;
|
|
globalThis.IDBKeyRange = fakeIndexedDB.IDBKeyRange;
|
|
}
|
|
|
|
import storageService from "../js/services/storageService.js";
|
|
import {
|
|
connectionProvider,
|
|
findProviderConnection,
|
|
computeDefaultForUploads,
|
|
legacyDuplicateIds,
|
|
saveProviderConnection,
|
|
fillStorageForm,
|
|
} from "../js/ui/profileModal/storageConnections.js";
|
|
|
|
// --- Pure decision logic ---
|
|
|
|
describe("fillStorageForm provider selector", () => {
|
|
// getConnection returns { ...payload, meta } — the provider is only in `meta` for
|
|
// the decrypted/keyless shape (payload has no top-level provider). The selector
|
|
// must reflect it, not silently revert to Cloudflare.
|
|
test("selects the connection's provider from meta when there's no top-level provider", () => {
|
|
const c = { storageProviderInput: { value: "cloudflare_r2" }, updateStorageFormVisibility: () => {}, handlePublicUrlInput: () => {} };
|
|
fillStorageForm(c, { meta: { provider: "backblaze_b2" } });
|
|
assert.equal(c.storageProviderInput.value, "backblaze_b2");
|
|
});
|
|
|
|
test("selects a keyless Blossom default (no payload/provider, provider only in meta)", () => {
|
|
const c = { storageProviderInput: { value: "cloudflare_r2" }, updateStorageFormVisibility: () => {}, handlePublicUrlInput: () => {} };
|
|
fillStorageForm(c, { meta: { provider: "blossom", defaultForUploads: true } });
|
|
assert.equal(c.storageProviderInput.value, "blossom");
|
|
});
|
|
|
|
test("prefers a top-level provider when present", () => {
|
|
const c = { storageProviderInput: { value: "" }, updateStorageFormVisibility: () => {}, handlePublicUrlInput: () => {} };
|
|
fillStorageForm(c, { provider: "generic_s3", meta: { provider: "cloudflare_r2" } });
|
|
assert.equal(c.storageProviderInput.value, "generic_s3");
|
|
});
|
|
|
|
test("falls back to cloudflare_r2 only when no provider is known", () => {
|
|
const c = { storageProviderInput: { value: "blossom" }, updateStorageFormVisibility: () => {}, handlePublicUrlInput: () => {} };
|
|
fillStorageForm(c, { meta: {} });
|
|
assert.equal(c.storageProviderInput.value, "cloudflare_r2");
|
|
});
|
|
});
|
|
|
|
describe("per-provider connection helpers", () => {
|
|
test("computeDefaultForUploads: explicit choice, first-ever, and 'was already default'", () => {
|
|
// Explicit checkbox always wins.
|
|
assert.equal(
|
|
computeDefaultForUploads({ isDefault: true, connections: [{ id: "x" }], provider: "backblaze_b2" }),
|
|
true,
|
|
);
|
|
// The very first connection becomes the default automatically.
|
|
assert.equal(
|
|
computeDefaultForUploads({ isDefault: false, connections: [], provider: "backblaze_b2" }),
|
|
true,
|
|
);
|
|
// Re-saving the provider that was already the default keeps it default...
|
|
assert.equal(
|
|
computeDefaultForUploads({
|
|
isDefault: false,
|
|
connections: [{ id: "cloudflare_r2", provider: "cloudflare_r2", meta: { defaultForUploads: true } }],
|
|
provider: "cloudflare_r2",
|
|
}),
|
|
true,
|
|
);
|
|
// ...but a non-default provider stays non-default (won't steal it).
|
|
assert.equal(
|
|
computeDefaultForUploads({
|
|
isDefault: false,
|
|
connections: [
|
|
{ id: "cloudflare_r2", provider: "cloudflare_r2", meta: { defaultForUploads: true } },
|
|
{ id: "backblaze_b2", provider: "backblaze_b2", meta: { defaultForUploads: false } },
|
|
],
|
|
provider: "backblaze_b2",
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("findProviderConnection matches on provider (payload or meta)", () => {
|
|
const conns = [
|
|
{ id: "a", provider: "cloudflare_r2" },
|
|
{ id: "b", meta: { provider: "backblaze_b2" } },
|
|
];
|
|
assert.equal(findProviderConnection(conns, "backblaze_b2").id, "b");
|
|
assert.equal(findProviderConnection(conns, "generic_s3"), null);
|
|
assert.equal(connectionProvider(conns[1]), "backblaze_b2");
|
|
});
|
|
|
|
test("legacyDuplicateIds flags same-provider connections stored under a different id", () => {
|
|
const conns = [
|
|
{ id: "default", provider: "cloudflare_r2" }, // the legacy shared slot
|
|
{ id: "cloudflare_r2", provider: "cloudflare_r2" }, // the new per-provider slot
|
|
{ id: "backblaze_b2", provider: "backblaze_b2" },
|
|
];
|
|
assert.deepEqual(legacyDuplicateIds(conns, "cloudflare_r2", "cloudflare_r2"), ["default"]);
|
|
assert.deepEqual(legacyDuplicateIds(conns, "backblaze_b2", "backblaze_b2"), []);
|
|
});
|
|
});
|
|
|
|
// --- Integration against the real storageService (fake-indexeddb) ---
|
|
|
|
describe("saving multiple providers does not clash", () => {
|
|
const pubkey = "00".repeat(31) + "07";
|
|
const signer = {
|
|
nip44Encrypt: async (_pk, pt) => `enc_${pt}`,
|
|
nip44Decrypt: async (_pk, ct) => ct.replace(/^enc_/, ""),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
storageService.masterKeys.clear();
|
|
if (storageService.db) {
|
|
storageService.db.close();
|
|
storageService.db = null;
|
|
storageService.dbPromise = null;
|
|
}
|
|
await new Promise((resolve) => {
|
|
const req = globalThis.indexedDB.deleteDatabase("bitvid-storage");
|
|
req.onsuccess = resolve;
|
|
req.onerror = resolve;
|
|
req.onblocked = resolve;
|
|
});
|
|
await storageService.unlock(pubkey, { signer });
|
|
});
|
|
|
|
const r2Payload = { provider: "cloudflare_r2", accessKeyId: "r2k", secretAccessKey: "r2s", accountId: "acct" };
|
|
const b2Payload = { provider: "backblaze_b2", accessKeyId: "b2k", secretAccessKey: "b2s" };
|
|
|
|
test("R2 then B2 both persist under their own provider ids; only one is default", async () => {
|
|
await saveProviderConnection(storageService, pubkey, {
|
|
provider: "cloudflare_r2",
|
|
payload: r2Payload,
|
|
meta: { provider: "cloudflare_r2", bucket: "r2-bucket" },
|
|
isDefault: true,
|
|
});
|
|
await saveProviderConnection(storageService, pubkey, {
|
|
provider: "backblaze_b2",
|
|
payload: b2Payload,
|
|
meta: { provider: "backblaze_b2", bucket: "b2-bucket", region: "us-west-004" },
|
|
isDefault: false,
|
|
});
|
|
|
|
const conns = await storageService.listConnections(pubkey);
|
|
const ids = conns.map((c) => c.id).sort();
|
|
assert.deepEqual(ids, ["backblaze_b2", "cloudflare_r2"], "both providers stored, no overwrite");
|
|
|
|
const r2 = await storageService.getConnection(pubkey, "cloudflare_r2");
|
|
const b2 = await storageService.getConnection(pubkey, "backblaze_b2");
|
|
assert.equal(r2.meta.bucket, "r2-bucket");
|
|
assert.equal(b2.meta.bucket, "b2-bucket");
|
|
// Credentials are independent (B2 did not clobber R2).
|
|
assert.equal(r2.accessKeyId, "r2k");
|
|
assert.equal(b2.accessKeyId, "b2k");
|
|
// Exactly one default-for-uploads, and it's the one the user chose.
|
|
assert.equal(conns.filter((c) => c.meta?.defaultForUploads).length, 1);
|
|
assert.equal(r2.meta.defaultForUploads, true);
|
|
assert.equal(b2.meta.defaultForUploads, false);
|
|
});
|
|
|
|
test("re-saving B2 as the default moves the active target off R2 (still only one default)", async () => {
|
|
await saveProviderConnection(storageService, pubkey, { provider: "cloudflare_r2", payload: r2Payload, meta: { provider: "cloudflare_r2" }, isDefault: true });
|
|
await saveProviderConnection(storageService, pubkey, { provider: "backblaze_b2", payload: b2Payload, meta: { provider: "backblaze_b2" }, isDefault: false });
|
|
await saveProviderConnection(storageService, pubkey, { provider: "backblaze_b2", payload: b2Payload, meta: { provider: "backblaze_b2" }, isDefault: true });
|
|
|
|
const conns = await storageService.listConnections(pubkey);
|
|
assert.equal(conns.filter((c) => c.meta?.defaultForUploads).length, 1);
|
|
assert.equal((await storageService.getConnection(pubkey, "backblaze_b2")).meta.defaultForUploads, true);
|
|
assert.equal((await storageService.getConnection(pubkey, "cloudflare_r2")).meta.defaultForUploads, false);
|
|
});
|
|
|
|
test("a legacy shared 'default' slot is migrated to its per-provider id (no duplicate)", async () => {
|
|
// Simulate the old UI: an R2 connection under the shared "default" id.
|
|
await storageService.saveConnection(pubkey, "default", r2Payload, {
|
|
provider: "cloudflare_r2",
|
|
bucket: "legacy",
|
|
defaultForUploads: true,
|
|
});
|
|
|
|
// Re-saving R2 through the new path should re-key it and remove the legacy slot.
|
|
await saveProviderConnection(storageService, pubkey, {
|
|
provider: "cloudflare_r2",
|
|
payload: r2Payload,
|
|
meta: { provider: "cloudflare_r2", bucket: "legacy" },
|
|
isDefault: false,
|
|
});
|
|
|
|
const ids = (await storageService.listConnections(pubkey)).map((c) => c.id);
|
|
assert.ok(ids.includes("cloudflare_r2"));
|
|
assert.ok(!ids.includes("default"), "legacy 'default' slot removed");
|
|
// Default-for-uploads carried over from the migrated connection.
|
|
assert.equal((await storageService.getConnection(pubkey, "cloudflare_r2")).meta.defaultForUploads, true);
|
|
});
|
|
|
|
test("both connections are present in the exported account record (so sync carries them)", async () => {
|
|
await saveProviderConnection(storageService, pubkey, { provider: "cloudflare_r2", payload: r2Payload, meta: { provider: "cloudflare_r2" }, isDefault: true });
|
|
await saveProviderConnection(storageService, pubkey, { provider: "backblaze_b2", payload: b2Payload, meta: { provider: "backblaze_b2" }, isDefault: false });
|
|
|
|
const record = await storageService.exportAccountRecord(pubkey);
|
|
assert.deepEqual(
|
|
Object.keys(record.connections).sort(),
|
|
["backblaze_b2", "cloudflare_r2"],
|
|
"the synced record contains every provider's connection",
|
|
);
|
|
});
|
|
|
|
test("importing a synced record notifies connection-change observers (open UIs refresh)", async () => {
|
|
await saveProviderConnection(storageService, pubkey, {
|
|
provider: "cloudflare_r2",
|
|
payload: r2Payload,
|
|
meta: { provider: "cloudflare_r2" },
|
|
isDefault: true,
|
|
});
|
|
const record = await storageService.exportAccountRecord(pubkey);
|
|
|
|
let fired = null;
|
|
const off = storageService.onConnectionsChanged((e) => {
|
|
fired = e;
|
|
});
|
|
await storageService.importAccountRecord(pubkey, record);
|
|
off();
|
|
|
|
assert.deepEqual(
|
|
fired,
|
|
{ pubkey },
|
|
"a Nostr sync import fires a connection-change event so the open upload modal can refresh",
|
|
);
|
|
|
|
// Unsubscribe actually detaches — a second import must not re-fire this listener.
|
|
fired = null;
|
|
await storageService.importAccountRecord(pubkey, record);
|
|
assert.equal(fired, null, "off() detached the observer");
|
|
});
|
|
|
|
test("a Blossom-only (keyless) account syncs without an encryptedMasterKey", async () => {
|
|
// Blossom connections are keyless (encrypted: null), so a Blossom-only account
|
|
// legitimately has no encryptedMasterKey. Importing it must still work.
|
|
const record = {
|
|
pubkey,
|
|
connections: {
|
|
blossom: {
|
|
id: "blossom",
|
|
provider: "blossom",
|
|
meta: { provider: "blossom", servers: ["https://blossom.band"] },
|
|
encrypted: null,
|
|
},
|
|
},
|
|
};
|
|
await storageService.importAccountRecord(pubkey, record); // must not throw
|
|
const conns = await storageService.listConnections(pubkey);
|
|
assert.deepEqual(conns.map((c) => c.id), ["blossom"]);
|
|
assert.deepEqual(conns[0].meta.servers, ["https://blossom.band"]);
|
|
});
|
|
|
|
test("an encrypted (R2/S3) connection still requires the master-key envelope", async () => {
|
|
// A record carrying an encrypted payload can't be unlocked without the
|
|
// envelope — importing it without one must still be rejected.
|
|
const record = {
|
|
pubkey,
|
|
connections: {
|
|
default: {
|
|
id: "default",
|
|
provider: "cloudflare_r2",
|
|
meta: { provider: "cloudflare_r2" },
|
|
encrypted: { cipher: "deadbeef", iv: "00" },
|
|
},
|
|
},
|
|
};
|
|
await assert.rejects(
|
|
() => storageService.importAccountRecord(pubkey, record),
|
|
/Invalid storage account record/,
|
|
);
|
|
});
|
|
});
|