mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-09 02:42:30 +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:
@@ -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.
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user