mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-03 07:52:31 +00:00
Refine Markdown settings export and import
Apply the shared settings policy to Markdown files and retain local credentials and profile selections when connection sharing is disabled. Document the available manual sharing paths. Add export and import checks using isolated Obsidian sessions, including persistence and restart. Use the reviewed local Commonlib candidate for downstream validation.
This commit is contained in:
@@ -714,6 +714,8 @@ Save settings to a markdown file. You will be notified when new settings arrive.
|
||||
Setting key: writeCredentialsForSettingSync
|
||||
(Not recommended) If set, credentials will be stored in the file.
|
||||
|
||||
When this setting is disabled, the file omits Object Storage keys, authentication headers, other credentials, all connection profiles, and their active selections. Importing it retains the receiving device's local credentials and profiles. When enabled, the file includes credentials and complete connection profiles. For manual sharing, use the settings screen's plain-text export or an encrypted Setup URI.
|
||||
|
||||
#### Notify all setting files
|
||||
|
||||
Setting key: notifyAllSettingSyncFile
|
||||
|
||||
Generated
+4
-4
@@ -23,7 +23,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.34",
|
||||
"@vrtmrz/livesync-commonlib": "file:../commonlib/artifacts/vrtmrz-livesync-commonlib-0.1.35-3.tgz",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -4567,9 +4567,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.34",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.34.tgz",
|
||||
"integrity": "sha512-EeVpeFg3W43cpN+x0BZvFj9fN+eQFil9vohvmLLV1z/8bCTT0Hsq/bfRV59oL0qFNggPetkwyCZUusdfjajNOw==",
|
||||
"version": "0.1.35-3",
|
||||
"resolved": "file:../commonlib/artifacts/vrtmrz-livesync-commonlib-0.1.35-3.tgz",
|
||||
"integrity": "sha512-w+Hd+ZrPIJyfzMucnpJYsuXWYK04tPi0yLnwVp4fkRQ+zZoOEo9RABi3LD0aNuswO9Otlz4IwPWZiPnG1KMp1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
|
||||
+2
-1
@@ -93,6 +93,7 @@
|
||||
"test:e2e:obsidian:internal-metadata-migration": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts",
|
||||
"test:e2e:obsidian:internal-metadata-doctor": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts --doctor",
|
||||
"test:e2e:obsidian:setting-markdown-export": "tsx test/e2e-obsidian/scripts/setting-markdown-export.ts",
|
||||
"test:e2e:obsidian:setting-markdown-roundtrip": "tsx test/e2e-obsidian/scripts/setting-markdown-roundtrip.ts",
|
||||
"test:e2e:obsidian:upgrade-from-stable": "tsx test/e2e-obsidian/scripts/upgrade-from-stable.ts",
|
||||
"test:e2e:obsidian:local-suite": "tsx test/e2e-obsidian/scripts/local-suite.ts",
|
||||
"test:e2e:obsidian:local-suite:services": "tsx test/e2e-obsidian/scripts/local-suite.ts --manage-services",
|
||||
@@ -190,7 +191,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.34",
|
||||
"@vrtmrz/livesync-commonlib": "file:../commonlib/artifacts/vrtmrz-livesync-commonlib-0.1.35-3.tgz",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { parse, stringify } from "yaml";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_MINIO,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import { CLOUDFLARE_TURN_TYPE } from "@/integrations/cloudflare/settings";
|
||||
import { ModuleObsidianSettingsAsMarkdown } from "./ModuleObsidianSettingAsMarkdown";
|
||||
|
||||
vi.mock("@/deps", async () => {
|
||||
const yaml = await import("yaml");
|
||||
return { parseYaml: yaml.parse, stringifyYaml: yaml.stringify };
|
||||
});
|
||||
|
||||
function createHarness(includeCredentials = false, existingFile = false) {
|
||||
const settings: ObsidianLiveSyncSettings = {
|
||||
...structuredClone(DEFAULT_SETTINGS),
|
||||
remoteType: REMOTE_MINIO,
|
||||
remoteConfigurations: {},
|
||||
activeConfigurationId: "",
|
||||
settingSyncFile: "LiveSync/synthetic-settings.md",
|
||||
writeCredentialsForSettingSync: includeCredentials,
|
||||
accessKey: "synthetic-export-access-key",
|
||||
secretKey: "synthetic-export-secret-key",
|
||||
couchDB_PASSWORD: "synthetic-export-couch-password",
|
||||
encryptedCouchDBConnection: "synthetic-encrypted-connection",
|
||||
};
|
||||
const files = new Map<string, string>();
|
||||
if (existingFile) {
|
||||
files.set(
|
||||
settings.settingSyncFile,
|
||||
"Synthetic note\n````yaml:livesync-setting\n" + stringify(settings) + "\n````\n"
|
||||
);
|
||||
}
|
||||
const storageAccess = {
|
||||
isExists: vi.fn(async (path: string) => files.has(path)),
|
||||
ensureDir: vi.fn(async () => true),
|
||||
readFileText: vi.fn(async (path: string) => files.get(path) ?? ""),
|
||||
writeFileAuto: vi.fn(async (path: string, data: string) => {
|
||||
files.set(path, data);
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
const module = Object.assign(Object.create(ModuleObsidianSettingsAsMarkdown.prototype), {
|
||||
core: { settings, storageAccess },
|
||||
_log: vi.fn(),
|
||||
}) as ModuleObsidianSettingsAsMarkdown;
|
||||
return { module, settings, files, storageAccess };
|
||||
}
|
||||
|
||||
describe("ModuleObsidianSettingsAsMarkdown", () => {
|
||||
it.each([false, true])(
|
||||
"respects the sharing option for new and existing files (existing: %s)",
|
||||
async (existingFile) => {
|
||||
const { module, settings, files, storageAccess } = createHarness(false, existingFile);
|
||||
settings.P2P_managedType = CLOUDFLARE_TURN_TYPE;
|
||||
await module.saveSettingToMarkdown(settings.settingSyncFile);
|
||||
const content = files.get(settings.settingSyncFile)!;
|
||||
expect(storageAccess.writeFileAuto).toHaveBeenCalled();
|
||||
const written = parse(module.extractSettingFromWholeText(content).body);
|
||||
expect(written).not.toHaveProperty("couchDB_PASSWORD");
|
||||
expect(content).not.toContain(settings.encryptedCouchDBConnection);
|
||||
expect(Object.keys(written).filter((key) => ["accessKey", "secretKey"].includes(key))).toEqual([]);
|
||||
expect(content).not.toContain(settings.accessKey);
|
||||
expect(content).not.toContain(settings.secretKey);
|
||||
expect(module._log).toHaveBeenCalledWith(
|
||||
"When credential export is disabled, connection profiles are omitted. To share them, export the settings manually from the settings screen.",
|
||||
expect.any(Number)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("exports complete connection settings when sharing is enabled", async () => {
|
||||
const { module, settings, files } = createHarness(true);
|
||||
settings.couchDB_USER = "synthetic-export-couch-user";
|
||||
settings.passphrase = "synthetic-export-passphrase";
|
||||
settings.idDerivationKey = "synthetic-export-id-key";
|
||||
settings.jwtKey = "synthetic-export-jwt-key";
|
||||
settings.jwtKid = "synthetic-export-jwt-kid";
|
||||
settings.jwtSub = "synthetic-export-jwt-subject";
|
||||
settings.couchDB_CustomHeaders = "X-Couch-Export: synthetic-couch-header";
|
||||
settings.bucketCustomHeaders = "X-Bucket-Export: synthetic-bucket-header";
|
||||
settings.P2P_passphrase = "synthetic-export-p2p-passphrase";
|
||||
settings.P2P_managedType = CLOUDFLARE_TURN_TYPE;
|
||||
settings.P2P_managedId = "synthetic-managed-turn-key-id";
|
||||
settings.P2P_managedToken = "synthetic-managed-turn-token";
|
||||
const activeUri = ConnectionStringParser.serialize({
|
||||
type: "s3",
|
||||
settings: { ...settings, endpoint: "https://active.synthetic.invalid" },
|
||||
});
|
||||
const inactiveUri = ConnectionStringParser.serialize({
|
||||
type: "s3",
|
||||
settings: {
|
||||
...settings,
|
||||
endpoint: "https://inactive.synthetic.invalid",
|
||||
accessKey: "synthetic-inactive-access-key",
|
||||
secretKey: "synthetic-inactive-secret-key",
|
||||
},
|
||||
});
|
||||
const managedTurnUri = ConnectionStringParser.serialize({
|
||||
type: "p2p",
|
||||
settings: {
|
||||
...settings,
|
||||
P2P_roomID: "synthetic-managed-turn-room",
|
||||
P2P_relays: "wss://relay.synthetic.invalid",
|
||||
},
|
||||
});
|
||||
settings.activeConfigurationId = "object-storage-active";
|
||||
settings.P2P_ActiveRemoteConfigurationId = "p2p-managed";
|
||||
settings.remoteConfigurations = {
|
||||
"object-storage-active": {
|
||||
id: "object-storage-active",
|
||||
name: "Synthetic active profile",
|
||||
uri: activeUri,
|
||||
isEncrypted: false,
|
||||
},
|
||||
"object-storage-inactive": {
|
||||
id: "object-storage-inactive",
|
||||
name: "Synthetic inactive profile",
|
||||
uri: inactiveUri,
|
||||
isEncrypted: false,
|
||||
},
|
||||
"p2p-managed": {
|
||||
id: "p2p-managed",
|
||||
name: "Synthetic managed P2P profile",
|
||||
uri: managedTurnUri,
|
||||
isEncrypted: false,
|
||||
},
|
||||
};
|
||||
const originalSettings = structuredClone(settings);
|
||||
await module.saveSettingToMarkdown(settings.settingSyncFile);
|
||||
const content = files.get(settings.settingSyncFile)!;
|
||||
const written = parse(module.extractSettingFromWholeText(content).body);
|
||||
expect(written.accessKey).toBe(settings.accessKey);
|
||||
expect(written.secretKey).toBe(settings.secretKey);
|
||||
expect(written.couchDB_USER).toBe(settings.couchDB_USER);
|
||||
expect(written.couchDB_PASSWORD).toBe(settings.couchDB_PASSWORD);
|
||||
expect(written.passphrase).toBe(settings.passphrase);
|
||||
expect(written.idDerivationKey).toBe(settings.idDerivationKey);
|
||||
expect(written.jwtKey).toBe(settings.jwtKey);
|
||||
expect(written.jwtKid).toBe(settings.jwtKid);
|
||||
expect(written.jwtSub).toBe(settings.jwtSub);
|
||||
expect(written.couchDB_CustomHeaders).toBe(settings.couchDB_CustomHeaders);
|
||||
expect(written.bucketCustomHeaders).toBe(settings.bucketCustomHeaders);
|
||||
expect(written.P2P_passphrase).toBe(settings.P2P_passphrase);
|
||||
expect(written.remoteConfigurations).toEqual(settings.remoteConfigurations);
|
||||
expect(written.activeConfigurationId).toBe(settings.activeConfigurationId);
|
||||
expect(written.P2P_ActiveRemoteConfigurationId).toBe(settings.P2P_ActiveRemoteConfigurationId);
|
||||
expect(written).not.toHaveProperty("P2P_managedType");
|
||||
expect(written).not.toHaveProperty("P2P_managedId");
|
||||
expect(written).not.toHaveProperty("P2P_managedToken");
|
||||
expect(content).toContain(
|
||||
"When credential export is disabled, connection profiles are omitted. To share them, export the settings manually from the settings screen."
|
||||
);
|
||||
expect(content).not.toContain(settings.encryptedCouchDBConnection);
|
||||
expect(settings).toEqual(originalSettings);
|
||||
expect(module._log).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("When credential export is disabled"),
|
||||
expect.any(Number)
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the sharing option to active and inactive profiles", async () => {
|
||||
const { module, settings, files } = createHarness(false);
|
||||
const activeUri = ConnectionStringParser.serialize({
|
||||
type: "s3",
|
||||
settings: { ...settings, endpoint: "https://active.synthetic.invalid" },
|
||||
});
|
||||
const inactiveUri = ConnectionStringParser.serialize({
|
||||
type: "s3",
|
||||
settings: {
|
||||
...settings,
|
||||
endpoint: "https://inactive.synthetic.invalid",
|
||||
accessKey: "synthetic-inactive-access-key",
|
||||
secretKey: "synthetic-inactive-secret-key",
|
||||
},
|
||||
});
|
||||
const secrets = [
|
||||
settings.accessKey,
|
||||
settings.secretKey,
|
||||
"synthetic-inactive-access-key",
|
||||
"synthetic-inactive-secret-key",
|
||||
];
|
||||
settings.accessKey = "";
|
||||
settings.secretKey = "";
|
||||
settings.activeConfigurationId = "active";
|
||||
settings.remoteConfigurations = {
|
||||
active: { id: "active", name: "Synthetic active profile", uri: activeUri, isEncrypted: false },
|
||||
inactive: { id: "inactive", name: "Synthetic inactive profile", uri: inactiveUri, isEncrypted: false },
|
||||
};
|
||||
settings.P2P_ActiveRemoteConfigurationId = "p2p-active";
|
||||
await module.saveSettingToMarkdown(settings.settingSyncFile);
|
||||
const content = files.get(settings.settingSyncFile)!;
|
||||
const written = parse(module.extractSettingFromWholeText(content).body);
|
||||
expect(written).not.toHaveProperty("remoteConfigurations");
|
||||
expect(written).not.toHaveProperty("activeConfigurationId");
|
||||
expect(written).not.toHaveProperty("P2P_ActiveRemoteConfigurationId");
|
||||
expect(secrets.filter((value) => content.includes(value))).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains local connections when importing ordinary settings", async () => {
|
||||
const filename = "LiveSync/synthetic-settings.md";
|
||||
const activeUri = ConnectionStringParser.serialize({
|
||||
type: "s3",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
endpoint: "https://active.synthetic.invalid",
|
||||
accessKey: "synthetic-profile-access-key",
|
||||
secretKey: "synthetic-profile-secret-key",
|
||||
},
|
||||
});
|
||||
const inactiveUri = ConnectionStringParser.serialize({
|
||||
type: "s3",
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
endpoint: "https://inactive.synthetic.invalid",
|
||||
accessKey: "synthetic-inactive-access-key",
|
||||
secretKey: "synthetic-inactive-secret-key",
|
||||
},
|
||||
});
|
||||
const currentSettings: ObsidianLiveSyncSettings = {
|
||||
...structuredClone(DEFAULT_SETTINGS),
|
||||
remoteType: REMOTE_MINIO,
|
||||
settingSyncFile: filename,
|
||||
writeCredentialsForSettingSync: false,
|
||||
accessKey: "synthetic-local-access-key",
|
||||
secretKey: "synthetic-local-secret-key",
|
||||
couchDB_USER: "synthetic-local-user",
|
||||
couchDB_PASSWORD: "synthetic-local-password",
|
||||
couchDB_CustomHeaders: "X-Couch-Local: synthetic-couch-header",
|
||||
bucketCustomHeaders: "X-Bucket-Local: synthetic-bucket-header",
|
||||
jwtKey: "synthetic-local-jwt-key",
|
||||
jwtKid: "synthetic-local-kid",
|
||||
jwtSub: "synthetic-local-subject",
|
||||
passphrase: "synthetic-local-passphrase",
|
||||
idDerivationVersion: 1,
|
||||
idDerivationKey: "ab".repeat(32),
|
||||
activeConfigurationId: "object-storage-active",
|
||||
P2P_ActiveRemoteConfigurationId: "p2p-active",
|
||||
P2P_AutoStart: false,
|
||||
remoteConfigurations: {
|
||||
"object-storage-active": {
|
||||
id: "object-storage-active",
|
||||
name: "Synthetic active profile",
|
||||
uri: activeUri,
|
||||
isEncrypted: false,
|
||||
},
|
||||
"object-storage-inactive": {
|
||||
id: "object-storage-inactive",
|
||||
name: "Synthetic inactive profile",
|
||||
uri: inactiveUri,
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const markdownSettings = {
|
||||
settingSyncFile: filename,
|
||||
writeCredentialsForSettingSync: false,
|
||||
P2P_AutoStart: true,
|
||||
};
|
||||
const document = "Synthetic note\n````yaml:livesync-setting\n" + stringify(markdownSettings) + "\n````\n";
|
||||
const files = new Map([[filename, document]]);
|
||||
const storageAccess = {
|
||||
isExists: vi.fn(async (path: string) => files.has(path)),
|
||||
readFileText: vi.fn(async (path: string) => files.get(path) ?? ""),
|
||||
};
|
||||
let openApplyDialogue: ((anchor: HTMLAnchorElement) => void) | undefined;
|
||||
let applySettings: (() => void) | undefined;
|
||||
const askSelectStringDialogue = vi.fn(async () => "Apply settings");
|
||||
const applyExternalSettings = vi.fn(async (_settings: ObsidianLiveSyncSettings) => undefined);
|
||||
const module = Object.assign(Object.create(ModuleObsidianSettingsAsMarkdown.prototype), {
|
||||
core: {
|
||||
settings: currentSettings,
|
||||
storageAccess,
|
||||
confirm: {
|
||||
askInPopup: vi.fn((_key: string, _text: string, callback: (anchor: HTMLAnchorElement) => void) => {
|
||||
openApplyDialogue = callback;
|
||||
}),
|
||||
askSelectStringDialogue,
|
||||
},
|
||||
rebuilder: { scheduleRebuild: vi.fn(), scheduleFetch: vi.fn() },
|
||||
_services: {
|
||||
setting: {
|
||||
applyExternalSettings,
|
||||
clearUsedPassphrase: vi.fn(),
|
||||
},
|
||||
appLifecycle: { performRestart: vi.fn() },
|
||||
},
|
||||
},
|
||||
_log: vi.fn(),
|
||||
}) as ModuleObsidianSettingsAsMarkdown;
|
||||
|
||||
await module.checkAndApplySettingFromMarkdown(filename, false);
|
||||
expect(openApplyDialogue).toBeDefined();
|
||||
openApplyDialogue?.({
|
||||
set text(_value: string) {},
|
||||
addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => {
|
||||
applySettings = listener as () => void;
|
||||
},
|
||||
} as HTMLAnchorElement);
|
||||
applySettings?.();
|
||||
await vi.waitFor(() => expect(applyExternalSettings).toHaveBeenCalledOnce());
|
||||
|
||||
const appliedSettings = applyExternalSettings.mock.calls[0]![0];
|
||||
expect(appliedSettings.P2P_AutoStart).toBe(true);
|
||||
expect(appliedSettings.accessKey).toBe(currentSettings.accessKey);
|
||||
expect(appliedSettings.secretKey).toBe(currentSettings.secretKey);
|
||||
expect(appliedSettings.couchDB_USER).toBe(currentSettings.couchDB_USER);
|
||||
expect(appliedSettings.couchDB_PASSWORD).toBe(currentSettings.couchDB_PASSWORD);
|
||||
expect(appliedSettings.couchDB_CustomHeaders).toBe(currentSettings.couchDB_CustomHeaders);
|
||||
expect(appliedSettings.bucketCustomHeaders).toBe(currentSettings.bucketCustomHeaders);
|
||||
expect(appliedSettings.jwtKey).toBe(currentSettings.jwtKey);
|
||||
expect(appliedSettings.jwtKid).toBe(currentSettings.jwtKid);
|
||||
expect(appliedSettings.jwtSub).toBe(currentSettings.jwtSub);
|
||||
expect(appliedSettings.remoteConfigurations).toEqual(currentSettings.remoteConfigurations);
|
||||
expect(appliedSettings.activeConfigurationId).toBe(currentSettings.activeConfigurationId);
|
||||
expect(appliedSettings.P2P_ActiveRemoteConfigurationId).toBe(currentSettings.P2P_ActiveRemoteConfigurationId);
|
||||
expect(appliedSettings.idDerivationVersion).toBe(currentSettings.idDerivationVersion);
|
||||
expect(appliedSettings.idDerivationKey).toBe(currentSettings.idDerivationKey);
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
import { isObjectDifferent } from "octagonal-wheels/object";
|
||||
import { EVENT_SETTING_SAVED, eventHub } from "@/common/events";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { type FilePathWithPrefix, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
createMarkdownSettings,
|
||||
mergeMarkdownSettings,
|
||||
type MarkdownSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { parseYaml, stringifyYaml, type Editor, type MarkdownView } from "@/deps";
|
||||
import { LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
@@ -132,17 +133,8 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
|
||||
return;
|
||||
}
|
||||
|
||||
let settingToApply = { ...DEFAULT_SETTINGS } as ObsidianLiveSyncSettings;
|
||||
settingToApply = { ...settingToApply, ...newSetting };
|
||||
const settingToApply = mergeMarkdownSettings(newSetting, this.settings);
|
||||
preserveManagedTurnProfilesOnMarkdownImport(newSetting, this.settings, settingToApply);
|
||||
if (!settingToApply?.writeCredentialsForSettingSync) {
|
||||
//New setting does not contains credentials.
|
||||
settingToApply.couchDB_USER = this.settings.couchDB_USER;
|
||||
settingToApply.couchDB_PASSWORD = this.settings.couchDB_PASSWORD;
|
||||
settingToApply.passphrase = this.settings.passphrase;
|
||||
settingToApply.idDerivationVersion = this.settings.idDerivationVersion;
|
||||
settingToApply.idDerivationKey = this.settings.idDerivationKey;
|
||||
}
|
||||
const oldSetting = this.generateSettingForMarkdown(
|
||||
this.settings,
|
||||
settingToApply.writeCredentialsForSettingSync
|
||||
@@ -198,35 +190,21 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
|
||||
);
|
||||
}
|
||||
|
||||
generateSettingForMarkdown(
|
||||
settings?: ObsidianLiveSyncSettings,
|
||||
keepCredential?: boolean
|
||||
): Partial<ObsidianLiveSyncSettings> {
|
||||
generateSettingForMarkdown(settings?: ObsidianLiveSyncSettings, keepCredential?: boolean): MarkdownSettings {
|
||||
const saveData = { ...(settings ? settings : this.settings) } as Partial<ObsidianLiveSyncSettings>;
|
||||
delete saveData.encryptedCouchDBConnection;
|
||||
delete saveData.encryptedPassphrase;
|
||||
delete saveData.encryptedIdDerivationKey;
|
||||
delete saveData.additionalSuffixOfDatabaseName;
|
||||
if (!saveData.writeCredentialsForSettingSync && !keepCredential) {
|
||||
delete saveData.couchDB_USER;
|
||||
delete saveData.couchDB_PASSWORD;
|
||||
delete saveData.passphrase;
|
||||
delete saveData.idDerivationKey;
|
||||
delete saveData.jwtKey;
|
||||
delete saveData.jwtKid;
|
||||
delete saveData.jwtSub;
|
||||
delete saveData.couchDB_CustomHeaders;
|
||||
delete saveData.bucketCustomHeaders;
|
||||
const credentialsIncluded = saveData.writeCredentialsForSettingSync || keepCredential === true;
|
||||
if (!credentialsIncluded) {
|
||||
omitManagedTurnProfilesFromMarkdown(saveData);
|
||||
}
|
||||
omitManagedTurnProfilesFromMarkdown(saveData);
|
||||
return saveData;
|
||||
const includeCredentials = keepCredential === undefined ? undefined : credentialsIncluded;
|
||||
return createMarkdownSettings(saveData, includeCredentials);
|
||||
}
|
||||
|
||||
async saveSettingToMarkdown(filename: string) {
|
||||
const saveData = this.generateSettingForMarkdown();
|
||||
if (hasManagedTurnSettings(this.settings)) {
|
||||
if (!this.settings.writeCredentialsForSettingSync && hasManagedTurnSettings(this.settings)) {
|
||||
this._log(
|
||||
"Share TURN provider credentials through an encrypted Setup URI. Connection profiles are omitted from Markdown settings.",
|
||||
"When credential export is disabled, connection profiles are omitted. To share them, export the settings manually from the settings screen.",
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
@@ -242,6 +220,8 @@ If the name of this file matches the value of the "settingSyncFile" setting insi
|
||||
We can perform a command in this file.
|
||||
- \`Parse setting file\` : load the setting from the file.
|
||||
|
||||
When credential export is disabled, connection profiles are omitted. To share them, export the settings manually from the settings screen.
|
||||
|
||||
**Note** Please handle it with all of your care if you have configured to write credentials in.
|
||||
|
||||
|
||||
|
||||
@@ -237,6 +237,8 @@ The workflow also opens the Customisation Sync dialogue. `--case=visibility` che
|
||||
|
||||
`test:e2e:obsidian:setting-markdown-export` enables setting Markdown export, waits for the generated Markdown file in the vault, and verifies that credentials are omitted when `writeCredentialsForSettingSync=false`, including both the plaintext ID key and its encrypted local representation.
|
||||
|
||||
`test:e2e:obsidian:setting-markdown-roundtrip` generates Markdown with and without credentials through the real export command, copies each original file into a separate fresh Vault, and imports it through **Parse setting file** and the visible **Apply settings** dialogue. Each receiver starts with default plug-in settings and no connection profiles, with its own configuration encryption passphrase provided independently of the source through localStorage. The scenario verifies ordinary settings, conditional credentials and complete profiles, encrypted local persistence, and restoration after restarting the same Vault and profile without reseeding settings or localStorage. Imports without credentials may create new profiles through the existing legacy migration; those profiles must contain none of the omitted source credentials. This scenario requires no remote service and is included in the focused wrapper and local suite.
|
||||
|
||||
`test:e2e:obsidian:upgrade-from-stable` is the release-acceptance upgrade workflow. It installs the exact published 0.25.83 artefacts into an isolated Vault, verifies their pinned SHA-256 values, and then replaces only the plug-in artefacts with the current target while retaining the same Vault and isolated Obsidian profile. The first run downloads the old release into the ignored `_testdata/releases` cache; every later run verifies the cached bytes before use.
|
||||
|
||||
The workflow first exercises a non-empty legacy settings document which has no `isConfigured` or file-name case value. It verifies that 0.25.83 treats a default-equivalent document as unconfigured. That release can persist the inferred boolean during a later, unrelated settings-save event, so the runner accepts either an absent value or the inferred `false` on disk, then restores the same minimal pre-flag document deliberately before installing 1.0. The target independently proves its direct migration: the Vault remains unconfigured instead of receiving new-Vault recommendations, case-insensitive handling becomes explicit, no compatibility pause or acknowledgement marker is created while onboarding remains pending, and a second 1.0 start is idempotent. The absent marker is deliberately deferred while onboarding is pending; a later configured start records the current version if no other review is required. This fixture rewrite is limited to the missing-flag boundary; the configured transport upgrades use only state created and saved by 0.25.83 itself.
|
||||
|
||||
@@ -55,6 +55,7 @@ const testSteps: Step[] = [
|
||||
{ name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] },
|
||||
{ name: "internal Metadata Doctor", args: ["run", "test:e2e:obsidian:internal-metadata-doctor"] },
|
||||
{ name: "setting Markdown export", args: ["run", "test:e2e:obsidian:setting-markdown-export"] },
|
||||
{ name: "setting Markdown round-trip", args: ["run", "test:e2e:obsidian:setting-markdown-roundtrip"] },
|
||||
];
|
||||
|
||||
const manageCouchDb = process.argv.includes("--manage-couchdb") || process.argv.includes("--manage-services");
|
||||
|
||||
@@ -38,6 +38,7 @@ const focusedScenarios = new Set([
|
||||
"received-change-readiness",
|
||||
"internal-metadata-doctor",
|
||||
"setting-markdown-export",
|
||||
"setting-markdown-roundtrip",
|
||||
"upgrade-from-stable",
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { deriveIdKey } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
@@ -10,6 +11,16 @@ import { createTemporaryVault } from "../runner/vault.ts";
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||
|
||||
const settingPath = "LiveSync/settings-export.md";
|
||||
const localSettingsPassphrase = "e2e-local-settings-passphrase-fixture";
|
||||
const objectStorageCredentialValues = {
|
||||
accessKey: "e2e-object-storage-access-key-fixture",
|
||||
secretKey: "e2e-object-storage-secret-key-fixture",
|
||||
jwtKey: "e2e-jwt-key-fixture",
|
||||
jwtKid: "e2e-jwt-kid-fixture",
|
||||
jwtSub: "e2e-jwt-sub-fixture",
|
||||
couchDB_CustomHeaders: "X-E2E-CouchDB: synthetic-header-fixture",
|
||||
bucketCustomHeaders: "X-E2E-Bucket: synthetic-header-fixture",
|
||||
};
|
||||
|
||||
async function waitForFileContaining(
|
||||
vaultPath: string,
|
||||
@@ -63,6 +74,43 @@ async function configureSettingMarkdown(
|
||||
);
|
||||
}
|
||||
|
||||
async function configureObjectStorageSettingMarkdown(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
const settings = {
|
||||
settingSyncFile: settingPath,
|
||||
writeCredentialsForSettingSync: false,
|
||||
configPassphraseStore: "LOCALSTORAGE",
|
||||
remoteType: REMOTE_MINIO,
|
||||
couchDB_URI: "",
|
||||
couchDB_USER: "",
|
||||
couchDB_PASSWORD: "",
|
||||
couchDB_DBNAME: "",
|
||||
remoteConfigurations: {},
|
||||
activeConfigurationId: "",
|
||||
...objectStorageCredentialValues,
|
||||
useJWT: true,
|
||||
liveSync: false,
|
||||
syncOnSave: false,
|
||||
syncOnStart: false,
|
||||
periodicReplication: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
P2P_Enabled: false,
|
||||
P2P_AutoStart: false,
|
||||
};
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`await core.services.setting.applyExternalSettings(${JSON.stringify(settings)},true);`,
|
||||
"await core.services.setting.saveSettingData();",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
@@ -73,6 +121,7 @@ async function main(): Promise<void> {
|
||||
const vault = await createTemporaryVault();
|
||||
const idDerivationKey = await deriveIdKey("setting-markdown-export-independent-id-key-fixture");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
let objectStorageVault: Awaited<ReturnType<typeof createTemporaryVault>> | undefined;
|
||||
try {
|
||||
console.log(`Using Obsidian executable: ${binary}`);
|
||||
console.log(`Temporary vault: ${vault.path}`);
|
||||
@@ -120,10 +169,148 @@ async function main(): Promise<void> {
|
||||
);
|
||||
|
||||
console.log(`Generated setting Markdown without credentials: ${settingPath}`);
|
||||
|
||||
await session.app.stop();
|
||||
session = undefined;
|
||||
objectStorageVault = await createTemporaryVault();
|
||||
console.log(`Temporary Object Storage vault: ${objectStorageVault.path}`);
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
vault: objectStorageVault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: { configPassphraseStore: "LOCALSTORAGE" },
|
||||
localStorageEntries: { "ls-setting-passphrase": localSettingsPassphrase },
|
||||
});
|
||||
await configureObjectStorageSettingMarkdown(cli.binary, session.cliEnv);
|
||||
const objectStorageContent = await waitForFileContaining(objectStorageVault.path, settingPath, [
|
||||
(value) => value.includes("````yaml:livesync-setting"),
|
||||
(value) => value.includes(`settingSyncFile: ${settingPath}`),
|
||||
(value) => value.includes("remoteType: MINIO"),
|
||||
]);
|
||||
|
||||
const objectStorageDataPath = join(
|
||||
objectStorageVault.path,
|
||||
".obsidian",
|
||||
"plugins",
|
||||
"obsidian-livesync",
|
||||
"data.json"
|
||||
);
|
||||
const persistedObjectStorage = JSON.parse(await readFile(objectStorageDataPath, "utf-8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assertEqual(
|
||||
persistedObjectStorage.configPassphraseStore,
|
||||
"LOCALSTORAGE",
|
||||
"The Object Storage credential protection setting was not persisted."
|
||||
);
|
||||
const clearedProtectedSettings = {
|
||||
couchDB_URI: "",
|
||||
couchDB_USER: "",
|
||||
couchDB_PASSWORD: "",
|
||||
couchDB_DBNAME: "",
|
||||
accessKey: "",
|
||||
secretKey: "",
|
||||
jwtKey: "",
|
||||
jwtKid: "",
|
||||
jwtSub: "",
|
||||
couchDB_CustomHeaders: "",
|
||||
bucketCustomHeaders: "",
|
||||
};
|
||||
for (const [key, value] of Object.entries(clearedProtectedSettings)) {
|
||||
assertEqual(persistedObjectStorage[key], value, `Plaintext ${key} was not cleared from data.json.`);
|
||||
}
|
||||
const encryptedObjectStorageCredentials = persistedObjectStorage.encryptedCouchDBConnection;
|
||||
if (typeof encryptedObjectStorageCredentials !== "string" || encryptedObjectStorageCredentials.length === 0) {
|
||||
throw new Error("Object Storage credentials were not saved in encrypted local settings.");
|
||||
}
|
||||
const markdownSecrets = [localSettingsPassphrase, ...Object.values(objectStorageCredentialValues)];
|
||||
for (const secret of markdownSecrets) {
|
||||
assertEqual(objectStorageContent.includes(secret), false, "A protected setting leaked into Markdown.");
|
||||
}
|
||||
assertEqual(
|
||||
objectStorageContent.includes(encryptedObjectStorageCredentials),
|
||||
false,
|
||||
"Encrypted Object Storage credentials leaked into setting Markdown."
|
||||
);
|
||||
for (const [key, value] of Object.entries(persistedObjectStorage)) {
|
||||
if (key.startsWith("encrypted") && typeof value === "string" && value !== "") {
|
||||
assertEqual(
|
||||
objectStorageContent.includes(value),
|
||||
false,
|
||||
`Encrypted ${key} leaked into setting Markdown.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await session.app.stop();
|
||||
session = undefined;
|
||||
session = await startObsidianLiveSyncSession({ binary, cliBinary: cli.binary, vault: objectStorageVault });
|
||||
const restoredObjectStorage = await evalObsidianJson<Record<string, unknown>>(
|
||||
cli.binary,
|
||||
[
|
||||
"(()=>{",
|
||||
"const settings=app.plugins.plugins['obsidian-livesync'].core.settings;",
|
||||
"return JSON.stringify({",
|
||||
"remoteType:settings.remoteType,",
|
||||
"couchDB_URI:settings.couchDB_URI,",
|
||||
"couchDB_USER:settings.couchDB_USER,",
|
||||
"couchDB_PASSWORD:settings.couchDB_PASSWORD,",
|
||||
"couchDB_DBNAME:settings.couchDB_DBNAME,",
|
||||
"accessKey:settings.accessKey,",
|
||||
"secretKey:settings.secretKey,",
|
||||
"jwtKey:settings.jwtKey,",
|
||||
"jwtKid:settings.jwtKid,",
|
||||
"jwtSub:settings.jwtSub,",
|
||||
"couchDB_CustomHeaders:settings.couchDB_CustomHeaders,",
|
||||
"bucketCustomHeaders:settings.bucketCustomHeaders,",
|
||||
"configPassphraseStore:settings.configPassphraseStore,",
|
||||
"useJWT:settings.useJWT,",
|
||||
"liveSync:settings.liveSync,",
|
||||
"syncOnSave:settings.syncOnSave,",
|
||||
"syncOnStart:settings.syncOnStart,",
|
||||
"periodicReplication:settings.periodicReplication,",
|
||||
"syncOnFileOpen:settings.syncOnFileOpen,",
|
||||
"syncOnEditorSave:settings.syncOnEditorSave,",
|
||||
"P2P_Enabled:settings.P2P_Enabled,",
|
||||
"P2P_AutoStart:settings.P2P_AutoStart",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(restoredObjectStorage.remoteType, REMOTE_MINIO, "The Object Storage remote type was not restored.");
|
||||
for (const [key, value] of Object.entries({
|
||||
couchDB_URI: "",
|
||||
couchDB_USER: "",
|
||||
couchDB_PASSWORD: "",
|
||||
couchDB_DBNAME: "",
|
||||
liveSync: false,
|
||||
syncOnSave: false,
|
||||
syncOnStart: false,
|
||||
periodicReplication: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
configPassphraseStore: "LOCALSTORAGE",
|
||||
useJWT: true,
|
||||
P2P_Enabled: false,
|
||||
P2P_AutoStart: false,
|
||||
...objectStorageCredentialValues,
|
||||
})) {
|
||||
assertEqual(restoredObjectStorage[key], value, `Object Storage ${key} was not restored after restart.`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Persisted protected Object Storage settings, then restored them after restarting the same Object Storage Vault."
|
||||
);
|
||||
} finally {
|
||||
if (session) {
|
||||
await session.app.stop();
|
||||
}
|
||||
if (objectStorageVault) {
|
||||
await objectStorageVault.dispose();
|
||||
}
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import { deepStrictEqual } from "node:assert";
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "yaml";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { deriveIdKey } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { assertEqual } from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000);
|
||||
const sourcePassphrase = "e2e-markdown-source-settings-passphrase";
|
||||
const receiverPassphrase = "e2e-markdown-receiver-settings-passphrase";
|
||||
const diagnosticsDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
const ordinarySettings = {
|
||||
showVerboseLog: true,
|
||||
batchSaveMinimumDelay: 7,
|
||||
batchSaveMaximumDelay: 19,
|
||||
};
|
||||
const credentialKeys = [
|
||||
"couchDB_USER",
|
||||
"couchDB_PASSWORD",
|
||||
"couchDB_CustomHeaders",
|
||||
"jwtKey",
|
||||
"jwtKid",
|
||||
"jwtSub",
|
||||
"accessKey",
|
||||
"secretKey",
|
||||
"bucketCustomHeaders",
|
||||
"passphrase",
|
||||
"idDerivationKey",
|
||||
"P2P_passphrase",
|
||||
] as const;
|
||||
const connectionContextKeys = ["couchDB_URI", "couchDB_DBNAME", "endpoint", "bucket", "region"] as const;
|
||||
const protectedConnectionKeys = [
|
||||
"couchDB_URI",
|
||||
"couchDB_USER",
|
||||
"couchDB_PASSWORD",
|
||||
"couchDB_DBNAME",
|
||||
"couchDB_CustomHeaders",
|
||||
"jwtKey",
|
||||
"jwtKid",
|
||||
"jwtSub",
|
||||
"accessKey",
|
||||
"secretKey",
|
||||
"bucket",
|
||||
"endpoint",
|
||||
"bucketCustomHeaders",
|
||||
];
|
||||
|
||||
async function readCurrentSettings(cliBinary: string, session: ObsidianLiveSyncSession) {
|
||||
return await evalObsidianJson<ObsidianLiveSyncSettings>(
|
||||
cliBinary,
|
||||
"JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings())",
|
||||
session.cliEnv
|
||||
);
|
||||
}
|
||||
|
||||
async function readPersistedSettings(vaultPath: string): Promise<Record<string, unknown>> {
|
||||
return JSON.parse(
|
||||
await readFile(join(vaultPath, ".obsidian", "plugins", "obsidian-livesync", "data.json"), "utf8")
|
||||
);
|
||||
}
|
||||
|
||||
async function waitFor<T>(description: string, read: () => Promise<T>, ready: (value: T) => boolean): Promise<T> {
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 15000);
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const value = await read();
|
||||
if (ready(value)) return value;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${description}: ${String(lastError)}`);
|
||||
}
|
||||
|
||||
function assertEmptyReceiver(settings: ObsidianLiveSyncSettings) {
|
||||
for (const key of [...credentialKeys, ...connectionContextKeys]) {
|
||||
assertEqual(settings[key], DEFAULT_SETTINGS[key], `The receiver already had ${key}.`);
|
||||
}
|
||||
assertEqual(settings.idDerivationVersion, 0, "The receiver already had an independent ID key.");
|
||||
deepStrictEqual(settings.remoteConfigurations, {}, "The receiver already had connection profiles.");
|
||||
assertEqual(settings.activeConfigurationId, "", "The receiver already had a selected connection.");
|
||||
assertEqual(settings.P2P_ActiveRemoteConfigurationId, "", "The receiver already had a selected P2P connection.");
|
||||
assertEqual(settings.settingSyncFile, "", "The receiver already had setting Markdown enabled.");
|
||||
assertEqual(settings.showVerboseLog, false, "The receiver already had the ordinary setting fixture.");
|
||||
}
|
||||
|
||||
function assertImportedSettings(
|
||||
settings: ObsidianLiveSyncSettings,
|
||||
source: ObsidianLiveSyncSettings,
|
||||
includeCredentials: boolean,
|
||||
settingPath: string
|
||||
) {
|
||||
for (const [key, value] of Object.entries(ordinarySettings)) {
|
||||
assertEqual(settings[key as keyof ObsidianLiveSyncSettings], value, `Imported ${key} differs.`);
|
||||
}
|
||||
assertEqual(settings.settingSyncFile, settingPath, "The imported Markdown path differs.");
|
||||
assertEqual(settings.writeCredentialsForSettingSync, includeCredentials, "The credential export flag differs.");
|
||||
assertEqual(settings.remoteType, REMOTE_COUCHDB, "The imported remote type differs.");
|
||||
assertEqual(settings.configPassphraseStore, "LOCALSTORAGE", "The settings protection mode differs.");
|
||||
for (const key of connectionContextKeys) {
|
||||
assertEqual(settings[key], source[key], `Imported connection context ${key} differs.`);
|
||||
}
|
||||
for (const key of credentialKeys) {
|
||||
assertEqual(
|
||||
settings[key],
|
||||
includeCredentials ? source[key] : DEFAULT_SETTINGS[key],
|
||||
`Imported credential ${key} differs.`
|
||||
);
|
||||
}
|
||||
assertEqual(settings.idDerivationVersion, includeCredentials ? 1 : 0, "The imported ID key version differs.");
|
||||
if (includeCredentials) {
|
||||
deepStrictEqual(
|
||||
settings.remoteConfigurations,
|
||||
source.remoteConfigurations,
|
||||
"The complete profile group differs."
|
||||
);
|
||||
assertEqual(settings.activeConfigurationId, source.activeConfigurationId, "The selected connection differs.");
|
||||
assertEqual(
|
||||
settings.P2P_ActiveRemoteConfigurationId,
|
||||
source.P2P_ActiveRemoteConfigurationId,
|
||||
"The selected P2P connection differs."
|
||||
);
|
||||
} else {
|
||||
// Legacy migration may create new credential-free profiles from the retained flat connection fields.
|
||||
for (const id of Object.keys(source.remoteConfigurations)) {
|
||||
assertEqual(id in settings.remoteConfigurations, false, "An omitted source profile was imported.");
|
||||
assertEqual(settings.activeConfigurationId === id, false, "An omitted source selection was imported.");
|
||||
assertEqual(
|
||||
settings.P2P_ActiveRemoteConfigurationId === id,
|
||||
false,
|
||||
"An omitted P2P selection was imported."
|
||||
);
|
||||
}
|
||||
for (const profile of Object.values(settings.remoteConfigurations)) {
|
||||
assertEqual(profile.isEncrypted, false, "A migrated runtime profile was not decrypted.");
|
||||
ConnectionStringParser.parse(profile.uri);
|
||||
const uri = decodeURIComponent(profile.uri);
|
||||
for (const key of credentialKeys) {
|
||||
assertEqual(uri.includes(source[key]), false, `An omitted ${key} appeared in a migrated profile.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertProtectedPersistence(persisted: Record<string, unknown>, includeCredentials: boolean) {
|
||||
for (const key of protectedConnectionKeys) {
|
||||
assertEqual(persisted[key], "", `Imported plaintext ${key} was persisted.`);
|
||||
}
|
||||
if (typeof persisted.encryptedCouchDBConnection !== "string" || !persisted.encryptedCouchDBConnection) {
|
||||
throw new Error("The imported connection settings were not encrypted locally.");
|
||||
}
|
||||
if (includeCredentials) {
|
||||
for (const key of ["encryptedPassphrase", "encryptedIdDerivationKey"]) {
|
||||
if (typeof persisted[key] !== "string" || !persisted[key]) throw new Error(`${key} was not persisted.`);
|
||||
}
|
||||
assertEqual(persisted.passphrase, "", "The imported E2EE passphrase was persisted in plain text.");
|
||||
assertEqual(persisted.idDerivationKey, "", "The imported ID key was persisted in plain text.");
|
||||
}
|
||||
const profiles = persisted.remoteConfigurations as ObsidianLiveSyncSettings["remoteConfigurations"];
|
||||
for (const profile of Object.values(profiles)) {
|
||||
assertEqual(profile.isEncrypted, true, "An imported connection profile was persisted in plain text.");
|
||||
}
|
||||
}
|
||||
|
||||
async function importThroughUI(
|
||||
cliBinary: string,
|
||||
session: ObsidianLiveSyncSession,
|
||||
settingPath: string,
|
||||
label: string
|
||||
) {
|
||||
const opened = await evalObsidianJson<boolean>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const file=app.vault.getAbstractFileByPath(${JSON.stringify(settingPath)});`,
|
||||
"if(!file) throw new Error('The copied Markdown file is unavailable');",
|
||||
"await app.workspace.getLeaf(false).openFile(file,{state:{mode:'source'}});",
|
||||
"return JSON.stringify(app.commands.executeCommandById('obsidian-livesync:livesync-import-config'));",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(opened, true, "The real Markdown import command was unavailable.");
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const notice = page.locator(".notice").filter({ hasText: `Setting markdown ${settingPath}` });
|
||||
await notice
|
||||
.locator("a")
|
||||
.filter({ hasText: /^HERE$/u })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Ready for apply the setting.", uiTimeoutMs);
|
||||
await dialogue.screenshot({ path: join(diagnosticsDirectory, `setting-markdown-${label}-import.png`) });
|
||||
await dialogue.getByRole("button", { name: "Apply settings", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await dialogue.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const startedAt = Date.now();
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
await mkdir(diagnosticsDirectory, { recursive: true });
|
||||
const sourceVault = await createTemporaryVault("livesync-markdown-source-");
|
||||
const receivers: Awaited<ReturnType<typeof createTemporaryVault>>[] = [];
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
const results: Record<string, unknown>[] = [];
|
||||
const sourceSettings: ObsidianLiveSyncSettings = {
|
||||
...structuredClone(DEFAULT_SETTINGS),
|
||||
...ordinarySettings,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
configPassphraseStore: "LOCALSTORAGE",
|
||||
couchDB_URI: "https://couchdb.synthetic.invalid",
|
||||
couchDB_DBNAME: "synthetic-vault",
|
||||
couchDB_USER: "synthetic-couch-user",
|
||||
couchDB_PASSWORD: "synthetic-couch-password",
|
||||
couchDB_CustomHeaders: "X-Synthetic-Couch: synthetic-couch-header",
|
||||
useJWT: true,
|
||||
jwtAlgorithm: "HS256",
|
||||
jwtKey: "synthetic-jwt-key",
|
||||
jwtKid: "synthetic-jwt-kid",
|
||||
jwtSub: "synthetic-jwt-sub",
|
||||
endpoint: "https://storage.synthetic.invalid",
|
||||
bucket: "synthetic-bucket",
|
||||
region: "auto",
|
||||
accessKey: "synthetic-access-key",
|
||||
secretKey: "synthetic-secret-key",
|
||||
bucketCustomHeaders: "X-Synthetic-Bucket: synthetic-bucket-header",
|
||||
encrypt: true,
|
||||
passphrase: "synthetic-vault-passphrase",
|
||||
idDerivationVersion: 1,
|
||||
idDerivationKey: await deriveIdKey("synthetic-markdown-roundtrip-independent-id-key"),
|
||||
P2P_roomID: "synthetic-peer-room",
|
||||
P2P_passphrase: "synthetic-peer-passphrase",
|
||||
P2P_relays: "wss://relay.synthetic.invalid",
|
||||
isConfigured: false,
|
||||
liveSync: false,
|
||||
syncOnSave: false,
|
||||
syncOnStart: false,
|
||||
periodicReplication: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
P2P_Enabled: false,
|
||||
P2P_AutoStart: false,
|
||||
};
|
||||
sourceSettings.remoteConfigurations = Object.fromEntries(
|
||||
[
|
||||
{ id: "synthetic-couchdb", name: "Synthetic CouchDB", type: "couchdb" as const },
|
||||
{ id: "synthetic-storage", name: "Synthetic Object Storage", type: "s3" as const },
|
||||
{ id: "synthetic-peer", name: "Synthetic P2P", type: "p2p" as const },
|
||||
].map(({ id, name, type }) => [
|
||||
id,
|
||||
{ id, name, uri: ConnectionStringParser.serialize({ type, settings: sourceSettings }), isEncrypted: false },
|
||||
])
|
||||
);
|
||||
sourceSettings.activeConfigurationId = "synthetic-couchdb";
|
||||
sourceSettings.P2P_ActiveRemoteConfigurationId = "synthetic-peer";
|
||||
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
vault: sourceVault,
|
||||
pluginData: { configPassphraseStore: "LOCALSTORAGE" },
|
||||
localStorageEntries: { "ls-setting-passphrase": sourcePassphrase },
|
||||
});
|
||||
const exports: { includeCredentials: boolean; settingPath: string; label: string; sha256: string }[] = [];
|
||||
for (const includeCredentials of [true, false]) {
|
||||
const label = includeCredentials ? "with-credentials" : "without-credentials";
|
||||
const settingPath = `LiveSync/settings-${label}.md`;
|
||||
const configured = {
|
||||
...sourceSettings,
|
||||
settingSyncFile: settingPath,
|
||||
writeCredentialsForSettingSync: includeCredentials,
|
||||
};
|
||||
const exported = await evalObsidianJson<boolean>(
|
||||
cli.binary,
|
||||
"(async()=>{const core=app.plugins.plugins['obsidian-livesync'].core;" +
|
||||
`await core.services.setting.applyExternalSettings(${JSON.stringify(configured)},false);` +
|
||||
"return JSON.stringify(app.commands.executeCommandById('obsidian-livesync:livesync-export-config'));})()",
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(exported, true, "The real Markdown export command was unavailable.");
|
||||
const content = await waitFor(
|
||||
"the real Markdown export",
|
||||
() => readFile(join(sourceVault.path, settingPath), "utf8"),
|
||||
(value) => value.includes(`writeCredentialsForSettingSync: ${includeCredentials}`)
|
||||
);
|
||||
const body = content.match(/````yaml:livesync-setting\n([\s\S]*?)````/)?.[1];
|
||||
if (!body) throw new Error("The real export did not contain a settings code block.");
|
||||
const parsed = parse(body) as Record<string, unknown>;
|
||||
for (const key of ["encryptedCouchDBConnection", "encryptedPassphrase", "encryptedIdDerivationKey"]) {
|
||||
assertEqual(key in parsed, false, `Local ciphertext ${key} appeared in Markdown.`);
|
||||
}
|
||||
for (const key of [
|
||||
...credentialKeys,
|
||||
"remoteConfigurations",
|
||||
"activeConfigurationId",
|
||||
"P2P_ActiveRemoteConfigurationId",
|
||||
]) {
|
||||
assertEqual(key in parsed, includeCredentials, `Export presence differs for ${key}.`);
|
||||
}
|
||||
for (const secret of [
|
||||
sourcePassphrase,
|
||||
receiverPassphrase,
|
||||
...credentialKeys.map((key) => sourceSettings[key]),
|
||||
]) {
|
||||
if (!includeCredentials || secret === sourcePassphrase || secret === receiverPassphrase) {
|
||||
assertEqual(content.includes(secret), false, "An omitted secret appeared in the real export.");
|
||||
}
|
||||
}
|
||||
await copyFile(
|
||||
join(sourceVault.path, settingPath),
|
||||
join(diagnosticsDirectory, `setting-markdown-${label}.md`)
|
||||
);
|
||||
exports.push({
|
||||
includeCredentials,
|
||||
settingPath,
|
||||
label,
|
||||
sha256: createHash("sha256").update(content).digest("hex"),
|
||||
});
|
||||
console.log(`Generated actual Markdown ${label}.`);
|
||||
}
|
||||
await session.app.stop();
|
||||
session = undefined;
|
||||
|
||||
for (const { includeCredentials, settingPath, label, sha256 } of exports) {
|
||||
const caseStartedAt = Date.now();
|
||||
const receiver = await createTemporaryVault(`livesync-markdown-${label}-receiver-`);
|
||||
receivers.push(receiver);
|
||||
await mkdir(join(receiver.path, "LiveSync"), { recursive: true });
|
||||
await copyFile(join(sourceVault.path, settingPath), join(receiver.path, settingPath));
|
||||
assertEqual(
|
||||
createHash("sha256")
|
||||
.update(await readFile(join(receiver.path, settingPath), "utf8"))
|
||||
.digest("hex"),
|
||||
sha256,
|
||||
"The receiving Markdown differs from the generated original."
|
||||
);
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
vault: receiver,
|
||||
// This device-local protection key is independent of the empty plug-in settings and the source key.
|
||||
localStorageEntries: { "ls-setting-passphrase": receiverPassphrase },
|
||||
});
|
||||
assertEmptyReceiver(await readCurrentSettings(cli.binary, session));
|
||||
await importThroughUI(cli.binary, session, settingPath, label);
|
||||
const persisted = await waitFor(
|
||||
"the applied settings on disk",
|
||||
() => readPersistedSettings(receiver.path),
|
||||
(value) => value.showVerboseLog === true && value.settingSyncFile === settingPath
|
||||
);
|
||||
assertProtectedPersistence(persisted, includeCredentials);
|
||||
assertImportedSettings(
|
||||
await readCurrentSettings(cli.binary, session),
|
||||
sourceSettings,
|
||||
includeCredentials,
|
||||
settingPath
|
||||
);
|
||||
await session.app.stop();
|
||||
session = undefined;
|
||||
// Keep the actual Vault and profile; do not seed settings or localStorage on restart.
|
||||
session = await startObsidianLiveSyncSession({ binary, cliBinary: cli.binary, vault: receiver });
|
||||
assertImportedSettings(
|
||||
await readCurrentSettings(cli.binary, session),
|
||||
sourceSettings,
|
||||
includeCredentials,
|
||||
settingPath
|
||||
);
|
||||
assertProtectedPersistence(await readPersistedSettings(receiver.path), includeCredentials);
|
||||
await session.app.stop();
|
||||
session = undefined;
|
||||
results.push({
|
||||
includeCredentials,
|
||||
sha256,
|
||||
emptyReceiverVerified: true,
|
||||
uiImportVerified: true,
|
||||
encryptedPersistenceVerified: true,
|
||||
sameVaultRestartVerified: true,
|
||||
elapsedMs: Date.now() - caseStartedAt,
|
||||
});
|
||||
console.log(`Imported ${label} into empty settings, then verified encrypted persistence and restart.`);
|
||||
}
|
||||
const reportPath = join(diagnosticsDirectory, "setting-markdown-roundtrip.json");
|
||||
await writeFile(
|
||||
reportPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
pluginSHA256: createHash("sha256")
|
||||
.update(await readFile("main.js", "utf8"))
|
||||
.digest("hex"),
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
results,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + "\n"
|
||||
);
|
||||
console.log(`Markdown round-trip report: ${reportPath}`);
|
||||
} finally {
|
||||
if (session) await session.app.stop();
|
||||
for (const receiver of receivers) await receiver.dispose();
|
||||
await sourceVault.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+11
@@ -12,6 +12,17 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- Security: Fixed an issue where sensitive information could be included in exported configuration data under certain conditions. Updating is recommended. (refs #1218)
|
||||
- Improved configuration persistence and JSON conflict handling.
|
||||
|
||||
### Acknowledgements
|
||||
|
||||
Thank you for your contributions!
|
||||
|
||||
- @kimjansheden (#1218)
|
||||
|
||||
## 1.0.33
|
||||
|
||||
1st October, 2026
|
||||
|
||||
Reference in New Issue
Block a user