mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
All three paths (upload video pick, upload thumbnail pick, edit "Replace file") now route through the shared signer gate — silent kept-unlocked restore, else ONE passphrase prompt — then unlock storage and continue the same pick. Cancel/bad-passphrase abort quietly; a refused thumbnail pick restores the picker. Unlock glue extracted to uploadModalStorageUnlock.js (file-size budget); ensureSigner threaded via initUploadModal/initEditModal. Tests: tests/upload-edit-storage-unlock-gate.test.mjs (12). Co-Authored-By: Claude Fable 5 <[email protected]>
248 lines
9.7 KiB
JavaScript
248 lines
9.7 KiB
JavaScript
// #56: when storage is locked, the upload modal's file pickers and the edit
|
|
// modal's "Replace file" pickers must unlock inline — silent kept-unlocked
|
|
// restore or ONE passphrase prompt via the shared signer gate — and continue
|
|
// with the same file pick, instead of dead-ending ("Please unlock storage…",
|
|
// a silent no-op, or "Unlock your storage in the profile modal").
|
|
//
|
|
// test_integrity_note:
|
|
// change_type: ["new_tests"]
|
|
// scenarios:
|
|
// - id: SCN-upload-picker-inline-unlock
|
|
// given: "storage locked for the active pubkey; a signer gate that succeeds / is cancelled / fails"
|
|
// when: "ensureStorageUnlockedForUpload / handleThumbnailSelection run"
|
|
// then: "gate ok -> storage unlocked and pick proceeds; cancel/bad-passphrase -> quiet abort; other failures fall through to the regular unlock messaging"
|
|
// - id: SCN-edit-replace-file-inline-unlock
|
|
// given: "the edit modal's resolved storage connection is locked"
|
|
// when: "ensureUploadable runs"
|
|
// then: "the gate + storageService.unlock run and the re-resolved unlocked connection is returned; cancel aborts without unlocking"
|
|
// observable_outcomes:
|
|
// - "storageService.unlock called exactly when the gate allows it"
|
|
// - "cancelled gate -> no unlock attempt, no error toast"
|
|
// - "thumbnail picker state reset when the unlock is refused"
|
|
// determinism_controls:
|
|
// - "pure fake ctx objects; no DOM, no timers, no network"
|
|
// anti_cheat_rationale:
|
|
// prevents: ["hard-coded return value", "over-mocking internal logic"]
|
|
// relaxation:
|
|
// did_relax_any_assertion: false
|
|
|
|
import test from "node:test";
|
|
import { strict as assert } from "node:assert";
|
|
import { UploadModal } from "../js/ui/components/UploadModal.js";
|
|
import { ensureUploadable } from "../js/ui/components/editModalUpload.js";
|
|
|
|
const PUBKEY = "a".repeat(64);
|
|
const ensureUnlocked = UploadModal.prototype.ensureStorageUnlockedForUpload;
|
|
const handleThumb = UploadModal.prototype.handleThumbnailSelection;
|
|
|
|
// --- UploadModal.ensureStorageUnlockedForUpload ---
|
|
|
|
function makeUploadCtx({ unlockedAfterHandleUnlock = true, gate } = {}) {
|
|
const calls = { handleUnlock: 0, loadFromStorage: 0, errors: [], gates: [] };
|
|
let unlocked = false;
|
|
const ctx = {
|
|
getCurrentPubkey: () => PUBKEY,
|
|
storageService: { isUnlocked: () => unlocked },
|
|
ensureSigner:
|
|
gate === undefined
|
|
? null
|
|
: async (options) => {
|
|
calls.gates.push(options);
|
|
return gate;
|
|
},
|
|
handleUnlock: async () => {
|
|
calls.handleUnlock += 1;
|
|
if (unlockedAfterHandleUnlock) unlocked = true;
|
|
},
|
|
updateLockUi: () => {},
|
|
loadFromStorage: async () => {
|
|
calls.loadFromStorage += 1;
|
|
ctx.activeCredentials = { accessKeyId: "k" };
|
|
},
|
|
showError: (m) => calls.errors.push(m),
|
|
isStorageUnlocked: false,
|
|
activeCredentials: null,
|
|
};
|
|
return { ctx, calls, setUnlocked: (v) => (unlocked = v) };
|
|
}
|
|
|
|
test("upload picker: gate ok -> unlocks via handleUnlock and proceeds", async () => {
|
|
const { ctx, calls } = makeUploadCtx({ gate: { ok: true } });
|
|
const result = await ensureUnlocked.call(ctx);
|
|
assert.equal(result, true);
|
|
assert.equal(calls.gates.length, 1, "signer gate consulted once");
|
|
assert.equal(calls.gates[0].need, "encrypt");
|
|
assert.equal(calls.gates[0].pubkey, PUBKEY);
|
|
assert.equal(calls.handleUnlock, 1, "storage unlock ran after the gate");
|
|
assert.equal(ctx.isStorageUnlocked, true);
|
|
assert.equal(calls.errors.length, 0);
|
|
});
|
|
|
|
test("upload picker: cancelled gate aborts quietly (no unlock, no toast)", async () => {
|
|
const { ctx, calls } = makeUploadCtx({
|
|
gate: { ok: false, reason: "cancelled" },
|
|
});
|
|
assert.equal(await ensureUnlocked.call(ctx), false);
|
|
assert.equal(calls.handleUnlock, 0, "no unlock attempt after cancel");
|
|
assert.equal(calls.errors.length, 0, "cancel is silent");
|
|
assert.equal(ctx.isStorageUnlocked, false);
|
|
});
|
|
|
|
test("upload picker: bad passphrase aborts without a second toast", async () => {
|
|
const { ctx, calls } = makeUploadCtx({
|
|
gate: { ok: false, reason: "bad-passphrase" },
|
|
});
|
|
assert.equal(await ensureUnlocked.call(ctx), false);
|
|
assert.equal(calls.handleUnlock, 0);
|
|
assert.equal(calls.errors.length, 0, "the gate already showed its own toast");
|
|
});
|
|
|
|
test("upload picker: non-user gate failures fall through to handleUnlock's messaging", async () => {
|
|
const { ctx, calls } = makeUploadCtx({
|
|
gate: { ok: false, reason: "no-stored-key" },
|
|
unlockedAfterHandleUnlock: false,
|
|
});
|
|
assert.equal(await ensureUnlocked.call(ctx), false);
|
|
assert.equal(calls.handleUnlock, 1, "regular unlock path still runs");
|
|
});
|
|
|
|
test("upload picker: already-unlocked storage loads credentials without any gate", async () => {
|
|
const { ctx, calls, setUnlocked } = makeUploadCtx({ gate: { ok: true } });
|
|
setUnlocked(true);
|
|
assert.equal(await ensureUnlocked.call(ctx), true);
|
|
assert.equal(calls.gates.length, 0, "no prompt when storage is already unlocked");
|
|
assert.equal(calls.handleUnlock, 0);
|
|
assert.equal(calls.loadFromStorage, 1, "stale credentials refreshed");
|
|
assert.ok(ctx.activeCredentials);
|
|
});
|
|
|
|
test("thumbnail pick while locked: refused unlock resets the picker, no upload", async () => {
|
|
const calls = { reset: 0, uploads: 0 };
|
|
const ctx = {
|
|
storageConfigured: true,
|
|
isStorageUnlocked: false,
|
|
ensureStorageUnlockedForUpload: async () => false,
|
|
resetThumbnailPicker: () => {
|
|
calls.reset += 1;
|
|
},
|
|
mediaUploader: {
|
|
uploadThumbnail: async () => {
|
|
calls.uploads += 1;
|
|
return { url: "https://x/thumb.jpg" };
|
|
},
|
|
},
|
|
showError: () => {},
|
|
};
|
|
await handleThumb.call(ctx, { name: "t.jpg" });
|
|
assert.equal(calls.reset, 1, "picker state restored");
|
|
assert.equal(calls.uploads, 0, "upload never started");
|
|
});
|
|
|
|
// --- editModalUpload.ensureUploadable (Edit modal "Replace file") ---
|
|
|
|
function makeEditCtx({ gate, unlockThrows = false } = {}) {
|
|
const calls = { unlock: [], errors: [], gates: [], resolves: 0 };
|
|
let unlocked = false;
|
|
const conn = () => ({
|
|
configured: true,
|
|
unlocked,
|
|
credentials: unlocked ? { accessKeyId: "k" } : null,
|
|
provider: "s3",
|
|
});
|
|
const modal = {
|
|
mediaUploader: {
|
|
resolveActiveConnection: async () => {
|
|
calls.resolves += 1;
|
|
return conn();
|
|
},
|
|
},
|
|
storageService: {
|
|
unlock: async (pubkey, opts) => {
|
|
calls.unlock.push({ pubkey, signer: opts?.signer });
|
|
if (unlockThrows) throw new Error("boom");
|
|
unlocked = true;
|
|
},
|
|
},
|
|
getCurrentPubkey: () => PUBKEY,
|
|
ensureSigner:
|
|
gate === undefined
|
|
? null
|
|
: async (options) => {
|
|
calls.gates.push(options);
|
|
return gate;
|
|
},
|
|
showError: (m) => calls.errors.push(m),
|
|
};
|
|
return { modal, calls };
|
|
}
|
|
|
|
const capableSigner = { nip44Decrypt: () => "x" };
|
|
|
|
test("edit replace-file: locked connection unlocks inline and returns the live connection", async () => {
|
|
const { modal, calls } = makeEditCtx({ gate: { ok: true } });
|
|
const conn = await ensureUploadable(modal, { getSigner: () => capableSigner });
|
|
assert.ok(conn, "connection returned");
|
|
assert.equal(conn.unlocked, true);
|
|
assert.ok(conn.credentials, "credentials present after inline unlock");
|
|
assert.equal(calls.gates.length, 1, "signer gate consulted");
|
|
assert.deepEqual(calls.unlock[0].pubkey, PUBKEY);
|
|
assert.equal(calls.errors.length, 0);
|
|
assert.equal(calls.resolves, 2, "connection re-resolved after the unlock");
|
|
});
|
|
|
|
test("edit replace-file: cancelled gate aborts without touching storage", async () => {
|
|
const { modal, calls } = makeEditCtx({
|
|
gate: { ok: false, reason: "cancelled" },
|
|
});
|
|
const conn = await ensureUploadable(modal, { getSigner: () => capableSigner });
|
|
assert.equal(conn, null);
|
|
assert.equal(calls.unlock.length, 0, "no unlock attempt after cancel");
|
|
assert.equal(calls.errors.length, 0, "cancel is silent");
|
|
});
|
|
|
|
test("edit replace-file: no gate + no capable signer keeps the actionable profile-modal message", async () => {
|
|
const { modal, calls } = makeEditCtx({});
|
|
const conn = await ensureUploadable(modal, { getSigner: () => null });
|
|
assert.equal(conn, null);
|
|
assert.equal(calls.unlock.length, 0);
|
|
assert.equal(calls.errors.length, 1);
|
|
assert.match(calls.errors[0], /profile modal/i);
|
|
});
|
|
|
|
test("edit replace-file: extension signer requests permissions before unlocking", async () => {
|
|
const { modal, calls } = makeEditCtx({ gate: { ok: true } });
|
|
let permissionAsks = 0;
|
|
const conn = await ensureUploadable(modal, {
|
|
getSigner: () => ({ type: "extension", nip04Decrypt: () => "x" }),
|
|
requestPermissions: async () => {
|
|
permissionAsks += 1;
|
|
return { ok: true };
|
|
},
|
|
});
|
|
assert.ok(conn);
|
|
assert.equal(permissionAsks, 1, "permissions requested for extension signers");
|
|
assert.equal(calls.unlock.length, 1);
|
|
});
|
|
|
|
test("edit replace-file: unlock failure surfaces the error and returns null", async () => {
|
|
const { modal, calls } = makeEditCtx({ gate: { ok: true }, unlockThrows: true });
|
|
const conn = await ensureUploadable(modal, { getSigner: () => capableSigner });
|
|
assert.equal(conn, null);
|
|
assert.equal(calls.errors.length, 1);
|
|
assert.match(calls.errors[0], /Failed to unlock storage/);
|
|
});
|
|
|
|
test("edit replace-file: an already-unlocked connection passes straight through", async () => {
|
|
const { modal, calls } = makeEditCtx({ gate: { ok: true } });
|
|
modal.mediaUploader.resolveActiveConnection = async () => ({
|
|
configured: true,
|
|
unlocked: true,
|
|
credentials: { accessKeyId: "k" },
|
|
provider: "s3",
|
|
});
|
|
const conn = await ensureUploadable(modal, { getSigner: () => capableSigner });
|
|
assert.ok(conn);
|
|
assert.equal(calls.gates.length, 0, "no prompt when already unlocked");
|
|
assert.equal(calls.unlock.length, 0);
|
|
});
|