mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
uploads: locked-storage file picks unlock inline instead of dead-ending (#56)
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]>
This commit is contained in:
1 parent
7ead34378e
commit
e32298cc89
7 files changed
+494
-41
No files matched your search
@@ -25,6 +25,7 @@ export class EditModal {
|
||||
storageService = null,
|
||||
getCurrentPubkey = null,
|
||||
safeEncodeNpub = null,
|
||||
ensureSigner = null,
|
||||
eventTarget,
|
||||
container,
|
||||
} = {}) {
|
||||
@@ -39,6 +40,9 @@ export class EditModal {
|
||||
this.storageService = storageService || null;
|
||||
this.getCurrentPubkey =
|
||||
typeof getCurrentPubkey === "function" ? getCurrentPubkey : null;
|
||||
// Shared signer gate (#56): lets the "Replace file" pickers unlock storage
|
||||
// inline (cached keep-unlocked key or one passphrase prompt).
|
||||
this.ensureSigner = typeof ensureSigner === "function" ? ensureSigner : null;
|
||||
this.mediaUploader = new MediaUploader({
|
||||
r2Service,
|
||||
s3Service,
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
} from "../../nostrClientFacade.js";
|
||||
import { UI_FEEDBACK_DELAY_MS } from "../../constants.js";
|
||||
import { showConfirm } from "../confirmDialog.js";
|
||||
import {
|
||||
ensureStorageUnlockedForUpload,
|
||||
resetThumbnailPicker,
|
||||
promptStoredNsecUnlock,
|
||||
} from "./uploadModalStorageUnlock.js";
|
||||
|
||||
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
|
||||
|
||||
@@ -55,6 +60,7 @@ export class UploadModal {
|
||||
container,
|
||||
onRequestStorageSettings,
|
||||
onRequestUnlock,
|
||||
ensureSigner,
|
||||
getHashtagSuggestions,
|
||||
} = {}) {
|
||||
this.authService = authService || null;
|
||||
@@ -96,6 +102,10 @@ export class UploadModal {
|
||||
// Opens the login modal's unlock-saved-key (passphrase) flow — used when a
|
||||
// persisted nsec session is locked after a reload.
|
||||
this.onRequestUnlock = typeof onRequestUnlock === "function" ? onRequestUnlock : null;
|
||||
// Shared signer gate (app.ensureEncryptionCapableSigner): silently restores a
|
||||
// kept-unlocked key or shows ONE passphrase prompt, so the file pickers can
|
||||
// unlock storage inline instead of dead-ending (#56).
|
||||
this.ensureSigner = typeof ensureSigner === "function" ? ensureSigner : null;
|
||||
|
||||
this.root = null;
|
||||
this.isVisible = false;
|
||||
@@ -530,10 +540,14 @@ export class UploadModal {
|
||||
return;
|
||||
}
|
||||
if (!this.isStorageUnlocked) {
|
||||
this.showError("Please unlock storage before selecting a file.");
|
||||
// #56: unlock inline (silent restore or one passphrase prompt) and
|
||||
// continue with this same file pick instead of erroring.
|
||||
const unlocked = await this.ensureStorageUnlockedForUpload();
|
||||
if (!unlocked) {
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Start new session
|
||||
const currentUploadId = ++this.videoUploadId;
|
||||
@@ -628,12 +642,19 @@ export class UploadModal {
|
||||
}
|
||||
|
||||
async handleThumbnailSelection(file) {
|
||||
if (!this.storageConfigured || !this.isStorageUnlocked) {
|
||||
// We can't upload yet. Just hold it in state or warn?
|
||||
// Since UI disables the browse button until unlocked (mostly), we assume safe.
|
||||
// But if they just unlocked, we're good.
|
||||
if (!this.storageConfigured) {
|
||||
this.showError("Configure storage before uploading a thumbnail.");
|
||||
this.resetThumbnailPicker();
|
||||
return;
|
||||
}
|
||||
if (!this.isStorageUnlocked) {
|
||||
// #56: prompt to unlock inline instead of silently dropping the pick.
|
||||
const unlocked = await this.ensureStorageUnlockedForUpload();
|
||||
if (!unlocked) {
|
||||
this.resetThumbnailPicker();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const currentUploadId = ++this.thumbnailUploadId;
|
||||
|
||||
@@ -1011,41 +1032,18 @@ export class UploadModal {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns true if a locked persisted-nsec session was detected (for the active
|
||||
// account) and the re-unlock (passphrase) flow was opened, so the caller can stop.
|
||||
// #36 / #56 storage-unlock glue lives in uploadModalStorageUnlock.js (file-size
|
||||
// budget); these stay as instance methods so callers/tests are unchanged.
|
||||
async ensureStorageUnlockedForUpload() {
|
||||
return ensureStorageUnlockedForUpload(this);
|
||||
}
|
||||
|
||||
resetThumbnailPicker() {
|
||||
resetThumbnailPicker(this);
|
||||
}
|
||||
|
||||
promptStoredNsecUnlock(pubkey) {
|
||||
const client = this.authService?.nostrClient;
|
||||
if (!client || typeof client.getStoredSessionActorMetadata !== "function") {
|
||||
return false;
|
||||
}
|
||||
let meta = null;
|
||||
try {
|
||||
meta = client.getStoredSessionActorMetadata();
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") {
|
||||
return false;
|
||||
}
|
||||
const metaPubkey =
|
||||
typeof meta.pubkey === "string" ? meta.pubkey.trim().toLowerCase() : "";
|
||||
const activePubkey =
|
||||
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
|
||||
if (activePubkey && metaPubkey && metaPubkey !== activePubkey) {
|
||||
return false;
|
||||
}
|
||||
if (typeof this.onRequestUnlock !== "function") {
|
||||
return false;
|
||||
}
|
||||
this.showError(
|
||||
"Your saved key is locked after reloading. Re-enter your passphrase to unlock it, then try the upload again.",
|
||||
);
|
||||
try {
|
||||
this.onRequestUnlock();
|
||||
} catch (err) {
|
||||
userLogger.warn("[UploadModal] Failed to open saved-key unlock flow:", err);
|
||||
}
|
||||
return true;
|
||||
return promptStoredNsecUnlock(this, pubkey);
|
||||
}
|
||||
|
||||
updateLockUi() {
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
// and its rendered root (`context`).
|
||||
|
||||
import { devLogger, userLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
getActiveSigner,
|
||||
requestDefaultExtensionPermissions,
|
||||
} from "../../nostrClientFacade.js";
|
||||
|
||||
// Set a field's value AND mark it edited/unlocked so EditModal.collect treats it
|
||||
// as a user change (isEditing() reads readOnly===false || dataset.isEditing).
|
||||
@@ -24,7 +28,8 @@ function setStatus(el, text) {
|
||||
|
||||
// Resolve the active storage connection and surface a user-facing reason when
|
||||
// upload isn't possible. Returns the connection on success, null otherwise.
|
||||
async function ensureUploadable(modal) {
|
||||
// `deps` are injectable for tests only.
|
||||
export async function ensureUploadable(modal, deps = {}) {
|
||||
if (!modal.mediaUploader) {
|
||||
modal.showError("Uploads are unavailable in this view.");
|
||||
return null;
|
||||
@@ -40,7 +45,88 @@ async function ensureUploadable(modal) {
|
||||
return null;
|
||||
}
|
||||
if (!conn.unlocked || !conn.credentials) {
|
||||
modal.showError("Unlock your storage in the profile modal, then try again.");
|
||||
// #56: unlock inline (kept-unlocked restore or one passphrase prompt) and
|
||||
// continue with this same file pick instead of bouncing the user to the
|
||||
// profile modal.
|
||||
return tryInlineStorageUnlock(modal, deps);
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
|
||||
const UNLOCK_FALLBACK_MESSAGE =
|
||||
"Unlock your storage in the profile modal, then try again.";
|
||||
|
||||
async function tryInlineStorageUnlock(modal, deps = {}) {
|
||||
const getSigner =
|
||||
typeof deps.getSigner === "function" ? deps.getSigner : getActiveSigner;
|
||||
const requestPermissions =
|
||||
typeof deps.requestPermissions === "function"
|
||||
? deps.requestPermissions
|
||||
: requestDefaultExtensionPermissions;
|
||||
|
||||
const pubkey =
|
||||
typeof modal.getCurrentPubkey === "function" ? modal.getCurrentPubkey() : null;
|
||||
if (!pubkey || !modal.storageService) {
|
||||
modal.showError(UNLOCK_FALLBACK_MESSAGE);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof modal.ensureSigner === "function") {
|
||||
let gate = null;
|
||||
try {
|
||||
gate = await modal.ensureSigner({
|
||||
pubkey,
|
||||
need: "encrypt",
|
||||
promptMessage:
|
||||
"Re-enter your PIN / passphrase to unlock storage for this upload.",
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[editModalUpload] Signer gate failed:", error);
|
||||
}
|
||||
if (gate && gate.ok !== true) {
|
||||
// Cancel aborts quietly; a bad passphrase already showed its own toast.
|
||||
if (gate.reason !== "cancelled" && gate.reason !== "bad-passphrase") {
|
||||
modal.showError(UNLOCK_FALLBACK_MESSAGE);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const signer = getSigner();
|
||||
if (
|
||||
!signer ||
|
||||
(typeof signer.nip44Decrypt !== "function" &&
|
||||
typeof signer.nip04Decrypt !== "function")
|
||||
) {
|
||||
modal.showError(UNLOCK_FALLBACK_MESSAGE);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (signer.type === "extension" || signer.type === "nip07") {
|
||||
const permissionResult = await requestPermissions();
|
||||
if (!permissionResult?.ok) {
|
||||
modal.showError("Extension permissions are required to unlock storage.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
await modal.storageService.unlock(pubkey, { signer });
|
||||
} catch (error) {
|
||||
userLogger.error("[editModalUpload] Storage unlock failed:", error);
|
||||
modal.showError(
|
||||
"Failed to unlock storage: " + (error?.message || String(error)),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
let conn = null;
|
||||
try {
|
||||
conn = await modal.mediaUploader.resolveActiveConnection();
|
||||
} catch (error) {
|
||||
devLogger.warn("[editModalUpload] Failed to re-resolve storage connection:", error);
|
||||
}
|
||||
if (!conn?.unlocked || !conn?.credentials) {
|
||||
modal.showError(UNLOCK_FALLBACK_MESSAGE);
|
||||
return null;
|
||||
}
|
||||
return conn;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// js/ui/components/uploadModalStorageUnlock.js
|
||||
//
|
||||
// Storage-unlock glue for the Upload modal (#36 / #56), kept out of
|
||||
// UploadModal.js to stay within its file-size budget. Every function operates
|
||||
// on the UploadModal instance (`modal`); UploadModal exposes them as thin
|
||||
// prototype delegates so callers and tests keep using the instance methods.
|
||||
|
||||
import { devLogger, userLogger } from "../../utils/logger.js";
|
||||
|
||||
// #56: the file pickers unlock storage inline instead of dead-ending. The
|
||||
// shared signer gate (app.ensureEncryptionCapableSigner) restores a cached
|
||||
// keep-unlocked key silently or shows ONE passphrase prompt; handleUnlock
|
||||
// then unlocks storage with the restored signer (extension permissions +
|
||||
// credential reload included). Returns true when the upload can proceed.
|
||||
// Cancel / bad-passphrase abort quietly (the gate surfaces its own toast for
|
||||
// a wrong passphrase); other gate failures fall through to handleUnlock so
|
||||
// its existing messaging (saved-key flow / "no signer") still applies.
|
||||
export async function ensureStorageUnlockedForUpload(modal) {
|
||||
if (!modal.storageService) return false;
|
||||
const pubkey = modal.getCurrentPubkey ? modal.getCurrentPubkey() : null;
|
||||
if (!pubkey) return false;
|
||||
|
||||
if (!modal.storageService.isUnlocked(pubkey)) {
|
||||
if (typeof modal.ensureSigner === "function") {
|
||||
let gate = null;
|
||||
try {
|
||||
gate = await modal.ensureSigner({
|
||||
pubkey,
|
||||
need: "encrypt",
|
||||
promptMessage:
|
||||
"Re-enter your PIN / passphrase to unlock storage for this upload.",
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger?.warn?.("[UploadModal] Signer gate failed:", error);
|
||||
}
|
||||
if (
|
||||
gate &&
|
||||
gate.ok !== true &&
|
||||
(gate.reason === "cancelled" || gate.reason === "bad-passphrase")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
await modal.handleUnlock();
|
||||
}
|
||||
|
||||
const unlocked = modal.storageService.isUnlocked(pubkey);
|
||||
if (unlocked && (!modal.isStorageUnlocked || !modal.activeCredentials)) {
|
||||
modal.isStorageUnlocked = true;
|
||||
modal.updateLockUi();
|
||||
await modal.loadFromStorage();
|
||||
}
|
||||
modal.isStorageUnlocked = unlocked;
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
// Undo the picker-side effects of a thumbnail selection that couldn't upload
|
||||
// (placeholder shows the picked filename and the URL input is disabled).
|
||||
export function resetThumbnailPicker(modal) {
|
||||
if (modal.inputs?.thumbnailFile) {
|
||||
modal.inputs.thumbnailFile.value = "";
|
||||
}
|
||||
const thumbnail = modal.inputs?.thumbnail;
|
||||
if (thumbnail) {
|
||||
thumbnail.placeholder = "https://example.com/thumbnail.jpg";
|
||||
thumbnail.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns true if a locked persisted-nsec session was detected (for the active
|
||||
// account) and the re-unlock (passphrase) flow was opened, so the caller can
|
||||
// stop instead of dead-ending with "No signer available".
|
||||
export function promptStoredNsecUnlock(modal, pubkey) {
|
||||
const client = modal.authService?.nostrClient;
|
||||
if (!client || typeof client.getStoredSessionActorMetadata !== "function") {
|
||||
return false;
|
||||
}
|
||||
let meta = null;
|
||||
try {
|
||||
meta = client.getStoredSessionActorMetadata();
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") {
|
||||
return false;
|
||||
}
|
||||
const metaPubkey =
|
||||
typeof meta.pubkey === "string" ? meta.pubkey.trim().toLowerCase() : "";
|
||||
const activePubkey =
|
||||
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
|
||||
if (activePubkey && metaPubkey && metaPubkey !== activePubkey) {
|
||||
return false;
|
||||
}
|
||||
if (typeof modal.onRequestUnlock !== "function") {
|
||||
return false;
|
||||
}
|
||||
modal.showError(
|
||||
"Your saved key is locked after reloading. Re-enter your passphrase to unlock it, then try the upload again.",
|
||||
);
|
||||
try {
|
||||
modal.onRequestUnlock();
|
||||
} catch (err) {
|
||||
userLogger.warn("[UploadModal] Failed to open saved-key unlock flow:", err);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -51,6 +51,12 @@ export function initEditModal({
|
||||
storageService,
|
||||
getCurrentPubkey,
|
||||
safeEncodeNpub,
|
||||
// Shared signer gate (#56): lets the "Replace file" pickers unlock storage
|
||||
// inline (cached keep-unlocked key or one passphrase prompt).
|
||||
ensureSigner: (options) =>
|
||||
typeof app?.ensureEncryptionCapableSigner === "function"
|
||||
? app.ensureEncryptionCapableSigner(options)
|
||||
: null,
|
||||
eventTarget: events,
|
||||
container,
|
||||
});
|
||||
|
||||
@@ -61,6 +61,12 @@ export function initUploadModal({
|
||||
app.profileController.show("storage");
|
||||
}
|
||||
},
|
||||
// Shared signer gate (#56): restores a kept-unlocked key silently or shows
|
||||
// one passphrase prompt so the file pickers can unlock storage inline.
|
||||
ensureSigner: (options) =>
|
||||
typeof app?.ensureEncryptionCapableSigner === "function"
|
||||
? app.ensureEncryptionCapableSigner(options)
|
||||
: null,
|
||||
// Open the login modal's unlock-saved-key (passphrase) flow when a persisted
|
||||
// nsec session is locked after a reload.
|
||||
onRequestUnlock: () => {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
// #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);
|
||||
});
|
||||
Reference in new issue
Block a user