mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-17 14:52:32 +00:00
Preserve configuration encryption edits when applying settings
This commit is contained in:
@@ -133,7 +133,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
applySetting(keys: AllSettingItemKey[]) {
|
||||
for (const k of keys) {
|
||||
if (!this.isDirty(k)) continue;
|
||||
if (k in OnDialogSettingsDefault) {
|
||||
if (this.isConfigurationEncryptionSetting(k) || k in OnDialogSettingsDefault) {
|
||||
// //@ts-ignore
|
||||
// this.initialSettings[k] = this.editingSettings[k];
|
||||
continue;
|
||||
@@ -141,21 +141,21 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
this.copySettingValue(this.core.settings, this.editingSettings, k);
|
||||
this.copySettingValue(this.initialSettings, this.core.settings, k);
|
||||
}
|
||||
keys.forEach((e) => this.refreshSetting(e));
|
||||
keys.forEach((e) => {
|
||||
if (this.isConfigurationEncryptionSetting(e) && this.isDirty(e)) return;
|
||||
this.refreshSetting(e);
|
||||
});
|
||||
}
|
||||
applyAllSettings() {
|
||||
async applyAllSettings() {
|
||||
const changedKeys = (Object.keys(this.editingSettings ?? {}) as AllSettingItemKey[]).filter((e) =>
|
||||
this.isDirty(e)
|
||||
);
|
||||
await this.saveConfigurationEncryptionIfDirty(changedKeys);
|
||||
this.applySetting(changedKeys);
|
||||
this.reloadAllSettings();
|
||||
}
|
||||
|
||||
async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) {
|
||||
if (key == "configPassphrase") {
|
||||
compatGlobal.localStorage.setItem("ls-setting-passphrase", this.editingSettings?.[key] ?? "");
|
||||
return await Promise.resolve();
|
||||
}
|
||||
if (key == "deviceAndVaultName") {
|
||||
this.services.setting.setDeviceAndVaultName(this.editingSettings?.[key] ?? "");
|
||||
this.services.setting.saveDeviceAndVaultName();
|
||||
@@ -168,10 +168,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
*/
|
||||
async saveSettings(keys: AllSettingItemKey[]) {
|
||||
let hasChanged = false;
|
||||
const appliedKeys = [] as AllSettingItemKey[];
|
||||
for (const k of keys) {
|
||||
if (!this.isDirty(k)) continue;
|
||||
appliedKeys.push(k);
|
||||
const appliedKeys = keys.filter((k) => this.isDirty(k));
|
||||
await this.saveConfigurationEncryptionIfDirty(appliedKeys);
|
||||
for (const k of appliedKeys) {
|
||||
if (this.isConfigurationEncryptionSetting(k)) continue;
|
||||
if (k in OnDialogSettingsDefault) {
|
||||
await this.saveLocalSetting(k as keyof OnDialogSettings);
|
||||
this.copySettingValue(this.initialSettings, this.editingSettings, k);
|
||||
@@ -192,7 +192,32 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
.map((e) => Promise.resolve(e.handler(this.editingSettings[e.key])));
|
||||
await Promise.all(handlers);
|
||||
// }
|
||||
keys.forEach((e) => this.refreshSetting(e));
|
||||
keys.forEach((e) => {
|
||||
if (this.isConfigurationEncryptionSetting(e) && this.isDirty(e)) return;
|
||||
this.refreshSetting(e);
|
||||
});
|
||||
}
|
||||
|
||||
private isConfigurationEncryptionSetting(key: AllSettingItemKey): boolean {
|
||||
return key === "configPassphraseStore" || key === "configPassphrase";
|
||||
}
|
||||
|
||||
private async saveConfigurationEncryptionIfDirty(keys: AllSettingItemKey[]): Promise<void> {
|
||||
if (!keys.some((key) => this.isConfigurationEncryptionSetting(key) && this.isDirty(key))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const appliedConfigurationEncryption = {
|
||||
configPassphraseStore: this.editingSettings.configPassphraseStore,
|
||||
configPassphrase: this.editingSettings.configPassphrase,
|
||||
};
|
||||
await this.services.setting.changeConfigurationEncryption(
|
||||
appliedConfigurationEncryption.configPassphraseStore,
|
||||
appliedConfigurationEncryption.configPassphrase
|
||||
);
|
||||
|
||||
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphraseStore");
|
||||
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphrase");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,10 +297,35 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
* Reread all settings and request invalidate
|
||||
*/
|
||||
reloadAllSettings(skipUpdate: boolean = false) {
|
||||
const previousSettings = this._editingSettings;
|
||||
const previousSnapshot = this.initialSettings;
|
||||
const pendingConfigurationEncryption =
|
||||
previousSettings && previousSnapshot
|
||||
? {
|
||||
configPassphraseStore: isObjectDifferent(
|
||||
previousSettings.configPassphraseStore,
|
||||
previousSnapshot.configPassphraseStore
|
||||
)
|
||||
? { value: previousSettings.configPassphraseStore }
|
||||
: undefined,
|
||||
configPassphrase: isObjectDifferent(
|
||||
previousSettings.configPassphrase,
|
||||
previousSnapshot.configPassphrase
|
||||
)
|
||||
? { value: previousSettings.configPassphrase }
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
const localSetting = this.reloadAllLocalSettings();
|
||||
this._editingSettings = { ...this.core.settings, ...localSetting };
|
||||
this._editingSettings = { ...this.editingSettings, ...this.computeAllLocalSettings() };
|
||||
this.initialSettings = { ...this.editingSettings };
|
||||
if (pendingConfigurationEncryption?.configPassphraseStore) {
|
||||
this._editingSettings.configPassphraseStore = pendingConfigurationEncryption.configPassphraseStore.value;
|
||||
}
|
||||
if (pendingConfigurationEncryption?.configPassphrase) {
|
||||
this._editingSettings.configPassphrase = pendingConfigurationEncryption.configPassphrase.value;
|
||||
}
|
||||
if (!skipUpdate) this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -1020,7 +1070,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
if (!this.editingSettings.encrypt) {
|
||||
this.editingSettings.passphrase = "";
|
||||
}
|
||||
this.applyAllSettings();
|
||||
await this.applyAllSettings();
|
||||
await this.services.setting.suspendAllSync();
|
||||
await this.services.setting.suspendExtraSync();
|
||||
this.reloadAllSettings();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
const settingsInitialisationMocks = vi.hoisted(() => ({
|
||||
applySettingsWithInitialisationChoice: vi.fn(),
|
||||
@@ -75,6 +76,8 @@ beforeEach(() => {
|
||||
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
|
||||
loggerMocks.Logger.mockClear();
|
||||
vi.mocked($msg).mockClear();
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReset().mockReturnValue(null);
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockReset();
|
||||
});
|
||||
|
||||
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
|
||||
@@ -260,6 +263,9 @@ describe("ObsidianLiveSyncSettingTab Fresh Start Wipe", () => {
|
||||
describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
|
||||
function createSettingsTab() {
|
||||
const saveSettingData = vi.fn(async () => undefined);
|
||||
const suspendAllSync = vi.fn(async () => undefined);
|
||||
const suspendExtraSync = vi.fn(async () => undefined);
|
||||
const performRebuildDB = vi.fn(async () => undefined);
|
||||
const confirmWithMessage = vi.fn();
|
||||
const plugin = {
|
||||
app: {},
|
||||
@@ -276,8 +282,13 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
|
||||
setting: {
|
||||
saveSettingData,
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
suspendAllSync,
|
||||
suspendExtraSync,
|
||||
},
|
||||
},
|
||||
rebuilder: {
|
||||
$performRebuildDB: performRebuildDB,
|
||||
},
|
||||
},
|
||||
};
|
||||
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
|
||||
@@ -294,7 +305,15 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
|
||||
vi.spyOn(tab, "isPassphraseValid").mockResolvedValue(true);
|
||||
vi.spyOn(tab, "checkWorkingPassphrase").mockResolvedValue(true);
|
||||
const closeSetting = vi.spyOn(tab, "closeSetting").mockImplementation(() => undefined);
|
||||
return { tab, saveSettingData, confirmWithMessage, closeSetting };
|
||||
return {
|
||||
tab,
|
||||
saveSettingData,
|
||||
suspendAllSync,
|
||||
suspendExtraSync,
|
||||
performRebuildDB,
|
||||
confirmWithMessage,
|
||||
closeSetting,
|
||||
};
|
||||
}
|
||||
|
||||
it("keeps pending settings in the editing buffer when initialisation and the fallback are cancelled", async () => {
|
||||
@@ -365,6 +384,49 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
|
||||
expect(tab.core.settings.handleFilenameCaseSensitive).toBe(false);
|
||||
expect(closeSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not apply unrelated settings or start a rebuild when configuration encryption fails", async () => {
|
||||
const { tab, saveSettingData, suspendAllSync, suspendExtraSync, performRebuildDB, closeSetting } =
|
||||
createSettingsTab();
|
||||
const failure = new Error("configuration encryption could not be saved");
|
||||
const changeConfigurationEncryption = vi.fn(async () => {
|
||||
throw failure;
|
||||
});
|
||||
Object.assign(tab.core.services.setting, { changeConfigurationEncryption });
|
||||
const savedStore = "LOCALSTORAGE";
|
||||
const editedStore = "ASK_AT_LAUNCH";
|
||||
tab.core.settings.encrypt = false;
|
||||
tab.core.settings.configPassphraseStore = savedStore;
|
||||
tab.initialSettings!.configPassphraseStore = savedStore;
|
||||
tab.editingSettings.configPassphraseStore = editedStore;
|
||||
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
||||
tab.editingSettings.configPassphrase = "new custom passphrase";
|
||||
tab.editingSettings.handleFilenameCaseSensitive = true;
|
||||
const applySetting = vi.spyOn(tab, "applySetting");
|
||||
const reloadAllSettings = vi.spyOn(tab, "reloadAllSettings");
|
||||
const saveAllDirtySettings = vi.spyOn(tab, "saveAllDirtySettings");
|
||||
|
||||
await expect(tab.rebuildDB("localOnly")).rejects.toBe(failure);
|
||||
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase");
|
||||
expect(applySetting).not.toHaveBeenCalled();
|
||||
expect(tab.core.settings.handleFilenameCaseSensitive).toBe(false);
|
||||
expect(tab.initialSettings!.handleFilenameCaseSensitive).toBe(false);
|
||||
expect(tab.editingSettings.handleFilenameCaseSensitive).toBe(true);
|
||||
expect(tab.core.settings.configPassphraseStore).toBe(savedStore);
|
||||
expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore);
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase");
|
||||
expect(tab.editingSettings.configPassphraseStore).toBe(editedStore);
|
||||
expect(tab.editingSettings.configPassphrase).toBe("new custom passphrase");
|
||||
expect(suspendAllSync).not.toHaveBeenCalled();
|
||||
expect(suspendExtraSync).not.toHaveBeenCalled();
|
||||
expect(reloadAllSettings).not.toHaveBeenCalled();
|
||||
expect(saveAllDirtySettings).not.toHaveBeenCalled();
|
||||
expect(saveSettingData).not.toHaveBeenCalled();
|
||||
expect(closeSetting).not.toHaveBeenCalled();
|
||||
expect(performRebuildDB).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
@@ -463,4 +525,82 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(replacement).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("saves a custom configuration passphrase edit through the setting service", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
const changeConfigurationEncryption = vi.fn(async () => {
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("new custom passphrase");
|
||||
});
|
||||
Object.assign(tab.core.services.setting, { changeConfigurationEncryption });
|
||||
const store = "LOCALSTORAGE";
|
||||
tab.editingSettings.configPassphraseStore = store;
|
||||
tab.initialSettings!.configPassphraseStore = store;
|
||||
tab.editingSettings.configPassphrase = "new custom passphrase";
|
||||
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockClear();
|
||||
|
||||
await tab.saveSettings(["configPassphrase"]);
|
||||
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith(store, "new custom passphrase");
|
||||
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("new custom passphrase");
|
||||
});
|
||||
|
||||
it("keeps a later passphrase edit dirty while an earlier Apply is pending", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
let resolveApply!: () => void;
|
||||
const changeConfigurationEncryption = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveApply = resolve;
|
||||
})
|
||||
);
|
||||
Object.assign(tab.core.services.setting, { changeConfigurationEncryption });
|
||||
tab.editingSettings.configPassphraseStore = "LOCALSTORAGE";
|
||||
tab.initialSettings!.configPassphraseStore = "LOCALSTORAGE";
|
||||
tab.initialSettings!.configPassphrase = "previous passphrase";
|
||||
tab.editingSettings.configPassphrase = "passphrase A";
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("passphrase A");
|
||||
|
||||
const apply = tab.saveSettings(["configPassphrase"]);
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith("LOCALSTORAGE", "passphrase A");
|
||||
|
||||
tab.editingSettings.configPassphrase = "passphrase B";
|
||||
resolveApply();
|
||||
await apply;
|
||||
tab.reloadAllSettings();
|
||||
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("passphrase A");
|
||||
expect(tab.editingSettings.configPassphrase).toBe("passphrase B");
|
||||
expect(tab.isDirty("configPassphrase")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps both configuration encryption edits pending when applying them fails", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
const failure = new Error("configuration encryption could not be saved");
|
||||
const changeConfigurationEncryption = vi.fn(async () => {
|
||||
throw failure;
|
||||
});
|
||||
Object.assign(tab.core.services.setting, { changeConfigurationEncryption });
|
||||
const savedStore = "LOCALSTORAGE";
|
||||
const editedStore = "ASK_AT_LAUNCH";
|
||||
tab.core.settings.configPassphraseStore = savedStore;
|
||||
tab.initialSettings!.configPassphraseStore = savedStore;
|
||||
tab.editingSettings.configPassphraseStore = editedStore;
|
||||
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
||||
tab.editingSettings.configPassphrase = "new custom passphrase";
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockClear();
|
||||
|
||||
await expect(tab.saveSettings(["configPassphraseStore", "configPassphrase"])).rejects.toBe(failure);
|
||||
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase");
|
||||
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
|
||||
expect(tab.core.settings.configPassphraseStore).toBe(savedStore);
|
||||
expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore);
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase");
|
||||
expect(tab.editingSettings.configPassphraseStore).toBe(editedStore);
|
||||
expect(tab.editingSettings.configPassphrase).toBe("new custom passphrase");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,9 +17,66 @@ vi.mock("@/common/translation", () => ({
|
||||
setLang: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/worker/bgWorker", () => ({
|
||||
initialiseWorkerModule: vi.fn(),
|
||||
}));
|
||||
|
||||
import { ModuleLiveSyncMain } from "./ModuleLiveSyncMain.ts";
|
||||
|
||||
describe("ModuleLiveSyncMain", () => {
|
||||
it.each(["unlock", "cancel"] as const)(
|
||||
"keeps database and settings lifecycle behind a pending configuration load: %s",
|
||||
async (outcome) => {
|
||||
let unlock!: () => void;
|
||||
let cancel!: (error: Error) => void;
|
||||
const loadSettings = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
unlock = resolve;
|
||||
cancel = reject;
|
||||
})
|
||||
);
|
||||
const appLifecycle = {
|
||||
onWireUpEvents: vi.fn(async () => true),
|
||||
onInitialise: vi.fn(async () => true),
|
||||
onSettingLoaded: vi.fn(async () => true),
|
||||
onLoaded: vi.fn(async () => true),
|
||||
};
|
||||
const openDatabase = vi.fn(async () => undefined);
|
||||
const services = {
|
||||
context: { events: {} },
|
||||
setting: { loadSettings },
|
||||
appLifecycle,
|
||||
database: { openDatabase },
|
||||
databaseEvents: {},
|
||||
replicator: {},
|
||||
};
|
||||
const host = { services, core: { services, addOns: [] }, _log: vi.fn() };
|
||||
const pending = ModuleLiveSyncMain.prototype._onLiveSyncLoad.call(host as never);
|
||||
await vi.waitFor(() => expect(loadSettings).toHaveBeenCalledOnce());
|
||||
expect(appLifecycle.onSettingLoaded).not.toHaveBeenCalled();
|
||||
expect(openDatabase).not.toHaveBeenCalled();
|
||||
expect(appLifecycle.onLoaded).not.toHaveBeenCalled();
|
||||
|
||||
if (outcome === "cancel") {
|
||||
const rejected = expect(pending).rejects.toThrow("synthetic cancelled configuration load");
|
||||
cancel(new Error("synthetic cancelled configuration load"));
|
||||
await rejected;
|
||||
expect(appLifecycle.onSettingLoaded).not.toHaveBeenCalled();
|
||||
expect(openDatabase).not.toHaveBeenCalled();
|
||||
expect(appLifecycle.onLoaded).not.toHaveBeenCalled();
|
||||
} else {
|
||||
unlock();
|
||||
await expect(pending).resolves.toBe(true);
|
||||
expect(appLifecycle.onSettingLoaded).toHaveBeenCalledOnce();
|
||||
expect(openDatabase).toHaveBeenCalledOnce();
|
||||
expect(appLifecycle.onSettingLoaded.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
openDatabase.mock.invocationCallOrder[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it("reports a database preparation failure at the application boundary", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const log = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user