mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Saves push silently as before, but if the relay copy is NEWER than the one this device last pushed (another device changed it), the user is now asked to confirm before overwriting — preventing a silent multi-device clobber. No nag on normal saves. - js/services/settingsSyncFlags.js: track per-pubkey+kind last-pushed created_at (getSyncPushedAt/setSyncPushedAt). - js/services/syncConflict.js: pushWithConflictCheck — peek remote created_at (list-only, no decrypt), compare to last push, call confirmOverwrite only on a real conflict, then push strictly-newer than remote so the replace wins. - storage/walletSyncService push()/enable() route through it and accept a confirmOverwrite callback. - ProfileStorage/WalletController supply confirmOverwrite (window.confirm) and surface a "kept the newer copy" status when the user declines. tests/sync-conflict.test.mjs: no-remote→silent, newer→prompt(+decline blocks push), confirm→strictly-newer push, older→no prompt. Mutation-verified (ignoring the decline fails). Service-test twins gained exists(). Co-Authored-By: Claude Opus 4.8 <[email protected]>
129 lines
4.5 KiB
JavaScript
129 lines
4.5 KiB
JavaScript
// NWC wallet sync (todo #15): the wallet connection (a bearer SPENDING secret)
|
|
// is pushed to an encrypted note and pulled+applied on another device.
|
|
// Scenarios assert observable outcomes at the nwcSettings + sync boundary:
|
|
// - enable() pushes the current nwcUri (+defaultZap) under the wallet d-tag
|
|
// - pull() applies the pulled URI via updateActiveNwcSettings (restore)
|
|
// - disable() clears the published note and flips the opt-in flag
|
|
// - push() with no wallet connected does not publish
|
|
|
|
import "./test-helpers/setup-localstorage.mjs";
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
createWalletSyncService,
|
|
WALLET_SYNC_DTAG,
|
|
} from "../js/services/walletSyncService.js";
|
|
|
|
const PUBKEY = "b".repeat(64);
|
|
const URI = "nostr+walletconnect://deadbeef?relay=wss://relay.example&secret=cafe";
|
|
|
|
function makeNwcTwin(initial = {}) {
|
|
let settings = { nwcUri: "", defaultZap: undefined, ...initial };
|
|
return {
|
|
applied: [],
|
|
getActiveNwcSettings: () => ({ ...settings }),
|
|
updateActiveNwcSettings: async (partial) => {
|
|
settings = { ...settings, ...partial };
|
|
// record what was applied (do NOT log the secret in real code)
|
|
// eslint-disable-next-line no-unused-vars
|
|
const safe = { ...partial };
|
|
return { ...settings };
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeSyncTwin() {
|
|
let note = null;
|
|
return {
|
|
calls: { push: [], pull: 0, clear: 0 },
|
|
isAvailable: () => true,
|
|
async exists() {
|
|
return { exists: Boolean(note) && !note.cleared, createdAt: 0 };
|
|
},
|
|
async push(dTag, payload) {
|
|
this.calls.push.push({ dTag, payload });
|
|
note = { payload };
|
|
return { ok: true, accepted: 1, total: 1 };
|
|
},
|
|
async pull() {
|
|
this.calls.pull += 1;
|
|
if (!note || note.cleared) {
|
|
return { found: false, cleared: Boolean(note?.cleared) };
|
|
}
|
|
return { found: true, payload: note.payload, createdAt: 1 };
|
|
},
|
|
async clear() {
|
|
this.calls.clear += 1;
|
|
note = { cleared: true };
|
|
return { ok: true, accepted: 1, total: 1 };
|
|
},
|
|
};
|
|
}
|
|
|
|
test("enable() pushes the current wallet URI under the wallet d-tag", async () => {
|
|
localStorage.clear();
|
|
const nwc = makeNwcTwin({ nwcUri: URI, defaultZap: 500 });
|
|
const sync = makeSyncTwin();
|
|
const service = createWalletSyncService({ encryptedSync: sync, nwcSettings: nwc });
|
|
|
|
assert.equal(service.isEnabled(PUBKEY), false, "off by default");
|
|
const result = await service.enable(PUBKEY);
|
|
assert.equal(result.ok, true);
|
|
assert.equal(sync.calls.push.length, 1);
|
|
assert.equal(sync.calls.push[0].dTag, WALLET_SYNC_DTAG);
|
|
assert.deepEqual(sync.calls.push[0].payload, { nwcUri: URI, defaultZap: 500 });
|
|
assert.equal(service.isEnabled(PUBKEY), true, "opt-in flag persists");
|
|
});
|
|
|
|
test("pull() restores the wallet URI via updateActiveNwcSettings on a fresh device", async () => {
|
|
localStorage.clear();
|
|
const sharedSync = makeSyncTwin();
|
|
// Device A pushes.
|
|
await createWalletSyncService({
|
|
encryptedSync: sharedSync,
|
|
nwcSettings: makeNwcTwin({ nwcUri: URI, defaultZap: 21 }),
|
|
}).enable(PUBKEY);
|
|
|
|
// Device B (no wallet) pulls.
|
|
const nwcB = makeNwcTwin({ nwcUri: "" });
|
|
const serviceB = createWalletSyncService({ encryptedSync: sharedSync, nwcSettings: nwcB });
|
|
|
|
const pulled = await serviceB.pull(PUBKEY);
|
|
assert.equal(pulled.found, true);
|
|
assert.equal(pulled.imported, true);
|
|
assert.equal(
|
|
nwcB.getActiveNwcSettings().nwcUri,
|
|
URI,
|
|
"device B must now hold the restored wallet URI",
|
|
);
|
|
assert.equal(nwcB.getActiveNwcSettings().defaultZap, 21);
|
|
});
|
|
|
|
test("disable() clears the note and a later pull finds nothing", async () => {
|
|
localStorage.clear();
|
|
const nwc = makeNwcTwin({ nwcUri: URI });
|
|
const sync = makeSyncTwin();
|
|
const service = createWalletSyncService({ encryptedSync: sync, nwcSettings: nwc });
|
|
|
|
await service.enable(PUBKEY);
|
|
const cleared = await service.disable(PUBKEY);
|
|
assert.equal(cleared.ok, true);
|
|
assert.equal(sync.calls.clear, 1);
|
|
assert.equal(service.isEnabled(PUBKEY), false);
|
|
|
|
const pulled = await service.pull(PUBKEY);
|
|
assert.equal(pulled.found, false);
|
|
});
|
|
|
|
test("push() with no wallet connected does not publish", async () => {
|
|
localStorage.clear();
|
|
const nwc = makeNwcTwin({ nwcUri: "" });
|
|
const sync = makeSyncTwin();
|
|
const service = createWalletSyncService({ encryptedSync: sync, nwcSettings: nwc });
|
|
|
|
const result = await service.push(PUBKEY);
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.error, "nothing-to-sync");
|
|
assert.equal(sync.calls.push.length, 0);
|
|
});
|