mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-08 18:32:30 +00:00
Merge main for current Customisation Sync regression coverage
This commit is contained in:
@@ -1,105 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost";
|
||||
import TurnConfiguration from "@/features/P2PSync/TurnConfiguration.svelte";
|
||||
import { validateManagedTurnSettings } from "@/integrations/turnSettings";
|
||||
|
||||
interface Props {
|
||||
host: P2PReplicatorPaneHost;
|
||||
}
|
||||
|
||||
let { host }: Props = $props();
|
||||
let { host }: { host: P2PReplicatorPaneHost } = $props();
|
||||
const currentSettings = () => host.services.setting.currentSettings() as P2PSyncSetting;
|
||||
const initialSettings = currentSettings();
|
||||
|
||||
let savedTurnServers = $state(initialSettings.P2P_turnServers);
|
||||
let savedTurnUsername = $state(initialSettings.P2P_turnUsername);
|
||||
let savedTurnCredential = $state(initialSettings.P2P_turnCredential);
|
||||
let turnServers = $state(initialSettings.P2P_turnServers);
|
||||
let turnUsername = $state(initialSettings.P2P_turnUsername);
|
||||
let turnCredential = $state(initialSettings.P2P_turnCredential);
|
||||
|
||||
const isTurnServersModified = $derived(turnServers !== savedTurnServers);
|
||||
const isTurnUsernameModified = $derived(turnUsername !== savedTurnUsername);
|
||||
const isTurnCredentialModified = $derived(turnCredential !== savedTurnCredential);
|
||||
const isModified = $derived(
|
||||
isTurnServersModified || isTurnUsernameModified || isTurnCredentialModified
|
||||
);
|
||||
function turnSettings(settings: P2PSyncSetting) {
|
||||
return {
|
||||
P2P_roomID: settings.P2P_roomID,
|
||||
P2P_turnServers: settings.P2P_turnServers,
|
||||
P2P_turnUsername: settings.P2P_turnUsername,
|
||||
P2P_turnCredential: settings.P2P_turnCredential,
|
||||
P2P_managedType: settings.P2P_managedType,
|
||||
P2P_managedId: settings.P2P_managedId,
|
||||
P2P_managedToken: settings.P2P_managedToken,
|
||||
};
|
||||
}
|
||||
let draft = $state(turnSettings(currentSettings()));
|
||||
let saved = $state(JSON.stringify(turnSettings(currentSettings())));
|
||||
const isModified = $derived(JSON.stringify(draft) !== saved);
|
||||
const sourceError = $derived(validateManagedTurnSettings(draft));
|
||||
const sourceNeedsRoom = $derived(!!draft.P2P_managedType && (draft.P2P_roomID ?? "").trim() === "");
|
||||
|
||||
function loadSettings(settings: P2PSyncSetting): void {
|
||||
savedTurnServers = settings.P2P_turnServers;
|
||||
savedTurnUsername = settings.P2P_turnUsername;
|
||||
savedTurnCredential = settings.P2P_turnCredential;
|
||||
turnServers = savedTurnServers;
|
||||
turnUsername = savedTurnUsername;
|
||||
turnCredential = savedTurnCredential;
|
||||
const next = turnSettings(settings);
|
||||
draft = next;
|
||||
saved = JSON.stringify(next);
|
||||
}
|
||||
|
||||
onMount(() =>
|
||||
host.services.context.events.onEvent("setting-saved", (settings) => {
|
||||
loadSettings(settings as P2PSyncSetting);
|
||||
})
|
||||
);
|
||||
onMount(() => host.services.context.events.onEvent("setting-saved", () => loadSettings(currentSettings())));
|
||||
|
||||
async function save(): Promise<void> {
|
||||
await host.services.setting.applyPartial(
|
||||
{
|
||||
P2P_turnServers: turnServers,
|
||||
P2P_turnUsername: turnUsername,
|
||||
P2P_turnCredential: turnCredential,
|
||||
},
|
||||
true
|
||||
);
|
||||
if (sourceError || sourceNeedsRoom) return;
|
||||
const values = $state.snapshot(draft);
|
||||
await host.services.setting.updateSettings((settings) => {
|
||||
const next = { ...settings, ...values, remoteConfigurations: { ...settings.remoteConfigurations } };
|
||||
const profileId = settings.P2P_ActiveRemoteConfigurationId ||
|
||||
(settings.remoteType === REMOTE_P2P ? settings.activeConfigurationId : "");
|
||||
const selected = next.remoteConfigurations[profileId];
|
||||
if (selected?.uri.startsWith("sls+p2p://")) {
|
||||
upsertRemoteConfigurationInPlace(next, "p2p", { id: profileId, activateForP2P: true });
|
||||
} else if (values.P2P_managedType) {
|
||||
upsertRemoteConfigurationInPlace(next, "p2p", { activateForP2P: true });
|
||||
}
|
||||
return next;
|
||||
}, true);
|
||||
loadSettings(currentSettings());
|
||||
}
|
||||
|
||||
function revert(): void {
|
||||
turnServers = savedTurnServers;
|
||||
turnUsername = savedTurnUsername;
|
||||
turnCredential = savedTurnCredential;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="browser-p2p-transport-settings">
|
||||
<details>
|
||||
<summary>Optional TURN server settings</summary>
|
||||
<p>
|
||||
Configure TURN only when a direct peer-to-peer connection cannot be established.
|
||||
</p>
|
||||
<label class:is-dirty={isTurnServersModified}>
|
||||
<span>TURN Server URLs (comma-separated)</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="turn:turn.example.com:3478"
|
||||
bind:value={turnServers}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
/>
|
||||
</label>
|
||||
<label class:is-dirty={isTurnUsernameModified}>
|
||||
<span>TURN Username</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter TURN username"
|
||||
bind:value={turnUsername}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label class:is-dirty={isTurnCredentialModified}>
|
||||
<span>TURN Credential</span>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Enter TURN credential"
|
||||
bind:value={turnCredential}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<p>Configure TURN only when a direct peer-to-peer connection cannot be established.</p>
|
||||
<TurnConfiguration bind:settings={draft} />
|
||||
<div class="actions">
|
||||
<button type="button" class="button mod-cta" disabled={!isModified} onclick={save}>
|
||||
<button type="button" class="button mod-cta" disabled={!isModified || !!sourceError || sourceNeedsRoom} onclick={save}>
|
||||
Save TURN settings
|
||||
</button>
|
||||
<button type="button" class="button" disabled={!isModified} onclick={revert}>
|
||||
<button type="button" class="button" disabled={!isModified} onclick={() => loadSettings(currentSettings())}>
|
||||
Revert TURN settings
|
||||
</button>
|
||||
</div>
|
||||
@@ -107,27 +69,7 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.browser-p2p-transport-settings {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
p {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
label.is-dirty {
|
||||
background-color: var(--background-modifier-error);
|
||||
}
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.browser-p2p-transport-settings { margin-bottom: 1rem; }
|
||||
p { margin: 0.75rem 0; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
</style>
|
||||
|
||||
@@ -419,6 +419,30 @@ describe("runCommand abnormal cases", () => {
|
||||
expect(appliedSettings.useIndexedDBAdapter).toBe(false);
|
||||
});
|
||||
|
||||
it("setup imports managed TURN through the existing encrypted URI", async () => {
|
||||
const core = createCoreMock();
|
||||
const profiles = {
|
||||
turn: { id: "turn", name: "TURN", isEncrypted: false,
|
||||
uri: "sls+p2p://room?managedType=CF&managedId=turn-key&token=private-token" },
|
||||
};
|
||||
const passphrase = "setup-passphrase";
|
||||
const setupURI = await processSetting.encodeSettingsToSetupURI(
|
||||
{
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteConfigurations: profiles,
|
||||
},
|
||||
passphrase
|
||||
);
|
||||
expect(setupURI.startsWith(configURIBase)).toBe(true);
|
||||
expect(setupURI).not.toContain("private-token");
|
||||
core.services.context.standardIo.prompt.mockResolvedValue(passphrase);
|
||||
await runCommand(makeOptions("setup", [setupURI]), { ...context, core });
|
||||
expect(core.services.setting.applyExternalSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ remoteConfigurations: profiles }),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("setup rejects encoded URI when passphrase is wrong", async () => {
|
||||
const core = createCoreMock();
|
||||
const setupURI = await createSetupURI("correct-passphrase");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
|
||||
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
|
||||
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
|
||||
import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore";
|
||||
@@ -524,7 +525,9 @@ export async function main(
|
||||
useOfflineScanner(core);
|
||||
}
|
||||
// Register P2P replicator feature.
|
||||
p2pReplicator = useP2PReplicatorFeature(core);
|
||||
p2pReplicator = useP2PReplicatorFeature(core, undefined, undefined, {
|
||||
prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)),
|
||||
});
|
||||
// Add target filter to prevent internal files are handled
|
||||
core.services.vault.isTargetFile.addHandler(async (target) => {
|
||||
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.28-cli",
|
||||
"version": "1.0.32-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
# 3. DB-deleted file → NOT restored to storage (UPDATE STORAGE skip)
|
||||
# 4. Both, storage newer → DB updated (SYNC: STORAGE → DB)
|
||||
# 5. Both, DB newer → storage updated (SYNC: DB → STORAGE)
|
||||
# 6. Compatibility mode → omitted vault-path works
|
||||
# 7. Unknown local origin → conflict preserved, deduplicated, and resolved
|
||||
#
|
||||
# Not covered (require precise mtime control or artificial conflict injection):
|
||||
# - Both, equal mtime → no-op (EVEN)
|
||||
@@ -43,7 +45,8 @@ cli_test_init_settings_file "$SETTINGS_FILE"
|
||||
# isConfigured=true is required for mirror (canProceedScan checks this)
|
||||
cli_test_mark_settings_configured "$SETTINGS_FILE"
|
||||
|
||||
# Enable writeDocumentsIfConflicted to resolve unsynced conflicts during mirror
|
||||
# Allow incoming DB content to be reflected when conflicts exist (Case 5).
|
||||
# This does not resolve conflicts or authorise overwriting DB content.
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = process.argv[1];
|
||||
@@ -181,6 +184,11 @@ echo "=== Case 4: storage newer → DB updated (Separated Paths) ==="
|
||||
# Seed DB with old content (mtime ≈ now)
|
||||
printf 'old content\n' | run_cli "$DB_DIR" --settings "$DB_SETTINGS" put test/sync-storage-newer.md
|
||||
|
||||
# Establish the file's recorded base before making an ordinary local edit.
|
||||
# A direct put followed by unrelated local content has unknown provenance.
|
||||
run_mirror_test
|
||||
cli_test_assert_equal "old content" "$(cat "$VAULT_DIR/test/sync-storage-newer.md")" "Case 4 base was not reflected"
|
||||
|
||||
# Write new content to storage with a timestamp 1 hour in the future
|
||||
printf 'new content\n' > "$VAULT_DIR/test/sync-storage-newer.md"
|
||||
touch -t "$(portable_touch_timestamp '+1 hour')" "$VAULT_DIR/test/sync-storage-newer.md"
|
||||
@@ -188,6 +196,8 @@ touch -t "$(portable_touch_timestamp '+1 hour')" "$VAULT_DIR/test/sync-storage-n
|
||||
run_mirror_test
|
||||
|
||||
DB_RESULT_FILE="$WORK_DIR/case4-pull.txt"
|
||||
CASE4_INFO="$(run_cli "$DB_DIR" --settings "$DB_SETTINGS" info test/sync-storage-newer.md)"
|
||||
cli_test_assert_equal "N/A" "$(printf '%s' "$CASE4_INFO" | cli_test_json_string_field_from_stdin conflicts)" "Ordinary local edit unexpectedly created a conflict"
|
||||
run_cli "$DB_DIR" --settings "$DB_SETTINGS" pull test/sync-storage-newer.md "$DB_RESULT_FILE"
|
||||
if cmp -s "$VAULT_DIR/test/sync-storage-newer.md" "$DB_RESULT_FILE"; then
|
||||
assert_pass "DB updated to match newer storage file"
|
||||
@@ -238,6 +248,55 @@ else
|
||||
assert_fail "Compatibility mode failed to sync file into DB"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Case 7: Unknown local origin must preserve both contents, regardless of mtime
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Case 7: unknown local origin → preserve and resolve conflict ==="
|
||||
|
||||
UNKNOWN_PATH="test/unknown-origin.md"
|
||||
printf 'original DB content\n' | run_cli "$DB_DIR" --settings "$DB_SETTINGS" put "$UNKNOWN_PATH"
|
||||
printf 'unrelated local content\n' > "$VAULT_DIR/$UNKNOWN_PATH"
|
||||
touch -t "$(portable_touch_timestamp '+1 hour')" "$VAULT_DIR/$UNKNOWN_PATH"
|
||||
run_mirror_test
|
||||
|
||||
UNKNOWN_INFO="$(run_cli "$DB_DIR" --settings "$DB_SETTINGS" info "$UNKNOWN_PATH")"
|
||||
WINNER="$(printf '%s' "$UNKNOWN_INFO" | cli_test_json_string_field_from_stdin revision)"
|
||||
CONFLICT="$(printf '%s' "$UNKNOWN_INFO" | cli_test_json_string_field_from_stdin conflicts)"
|
||||
if [[ ! "$WINNER" =~ ^1-[[:xdigit:]]+$ || ! "$CONFLICT" =~ ^1-[[:xdigit:]]+$ || "$WINNER" == "$CONFLICT" ]]; then
|
||||
echo "[FAIL] Expected two independent non-deleted root revisions: $UNKNOWN_INFO" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Do not assume which randomly identified root PouchDB selects as the winner.
|
||||
LOCAL_REV=""
|
||||
DB_REV=""
|
||||
for revision in "$WINNER" "$CONFLICT"; do
|
||||
CONTENT="$(run_cli "$DB_DIR" --settings "$DB_SETTINGS" cat-rev "$UNKNOWN_PATH" "$revision" | cli_test_sanitise_cat_stdout)"
|
||||
case "$CONTENT" in
|
||||
'unrelated local content') LOCAL_REV="$revision" ;;
|
||||
'original DB content') DB_REV="$revision" ;;
|
||||
*) echo "[FAIL] Unexpected content for $revision: $CONTENT" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$LOCAL_REV" && -n "$DB_REV" ]] || { echo "[FAIL] Both contents must remain readable" >&2; exit 1; }
|
||||
|
||||
# Force another ordinary save of the same unknown bytes, even if incoming
|
||||
# reflection replaced the file under writeDocumentsIfConflicted.
|
||||
printf 'unrelated local content\n' > "$VAULT_DIR/$UNKNOWN_PATH"
|
||||
touch -t "$(portable_touch_timestamp '+1 hour')" "$VAULT_DIR/$UNKNOWN_PATH"
|
||||
run_mirror_test
|
||||
REPEATED_INFO="$(run_cli "$DB_DIR" --settings "$DB_SETTINGS" info "$UNKNOWN_PATH")"
|
||||
cli_test_assert_equal "$WINNER" "$(printf '%s' "$REPEATED_INFO" | cli_test_json_string_field_from_stdin revision)" "Repeated mirror changed the winning revision"
|
||||
cli_test_assert_equal "$CONFLICT" "$(printf '%s' "$REPEATED_INFO" | cli_test_json_string_field_from_stdin conflicts)" "Repeated mirror created another conflict"
|
||||
|
||||
run_cli "$DB_DIR" --vault "$VAULT_DIR" --settings "$DB_SETTINGS" resolve "$UNKNOWN_PATH" "$LOCAL_REV"
|
||||
RESOLVED_INFO="$(run_cli "$DB_DIR" --settings "$DB_SETTINGS" info "$UNKNOWN_PATH")"
|
||||
cli_test_assert_equal "N/A" "$(printf '%s' "$RESOLVED_INFO" | cli_test_json_string_field_from_stdin conflicts)" "CLI resolve left a conflict"
|
||||
cli_test_assert_equal "$LOCAL_REV" "$(printf '%s' "$RESOLVED_INFO" | cli_test_json_string_field_from_stdin revision)" "CLI resolve selected the wrong revision"
|
||||
cli_test_assert_equal "unrelated local content" "$(cat "$VAULT_DIR/$UNKNOWN_PATH")" "CLI resolve did not reflect the selected content"
|
||||
assert_pass "Unknown local content was preserved, deduplicated, and resolved through the CLI"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Summary
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -628,7 +628,7 @@ export async function startP2pRelay(): Promise<void> {
|
||||
//TODO: port mapping should be configurable.
|
||||
"4000:7777",
|
||||
"--tmpfs",
|
||||
"/app/strfry-db:rw,size=256m",
|
||||
"/app/strfry-db:rw,size=256m,mode=1777",
|
||||
"--entrypoint",
|
||||
"sh",
|
||||
P2P_RELAY_IMAGE,
|
||||
|
||||
@@ -13,7 +13,11 @@ export async function initSettingsFile(settingsFile: string): Promise<void> {
|
||||
* Generate a full setup URI from a settings file via the Commonlib package API.
|
||||
* Mirrors the bash flow in test-setup-put-cat-linux.sh.
|
||||
*/
|
||||
export async function generateSetupUriFromSettings(settingsFile: string, setupPassphrase: string): Promise<string> {
|
||||
export async function generateSetupUriFromSettings(
|
||||
settingsFile: string,
|
||||
setupPassphrase: string,
|
||||
preserveRemoteSettings = false
|
||||
): Promise<string> {
|
||||
const script = [
|
||||
"import { fs } from '@vrtmrz/livesync-commonlib/node';",
|
||||
"import { encodeSettingsToSetupURI } from '@vrtmrz/livesync-commonlib/compat/API/processSetting';",
|
||||
@@ -21,13 +25,17 @@ export async function generateSetupUriFromSettings(settingsFile: string, setupPa
|
||||
" const settingsPath = process.env.SETTINGS_FILE;",
|
||||
" const passphrase = process.env.SETUP_PASSPHRASE;",
|
||||
" const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));",
|
||||
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
|
||||
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
|
||||
" settings.couchDB_USER = 'dummy';",
|
||||
" settings.couchDB_PASSWORD = 'dummy';",
|
||||
" settings.liveSync = false;",
|
||||
" settings.syncOnStart = false;",
|
||||
" settings.syncOnSave = false;",
|
||||
...(preserveRemoteSettings
|
||||
? []
|
||||
: [
|
||||
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
|
||||
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
|
||||
" settings.couchDB_USER = 'dummy';",
|
||||
" settings.couchDB_PASSWORD = 'dummy';",
|
||||
" settings.liveSync = false;",
|
||||
" settings.syncOnStart = false;",
|
||||
" settings.syncOnSave = false;",
|
||||
]),
|
||||
" const uri = await encodeSettingsToSetupURI(settings, passphrase);",
|
||||
" process.stdout.write(uri.trim());",
|
||||
"})();",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* 4. Both, storage newer -> DB updated (SYNC: STORAGE -> DB)
|
||||
* 5. Both, DB newer -> storage updated (SYNC: DB -> STORAGE)
|
||||
* 6. Compatibility mode -> omitted vault-path works (same DB + vault path)
|
||||
* 7. Unknown local origin -> conflict preserved, deduplicated, and resolved
|
||||
*
|
||||
* No external services are required.
|
||||
*
|
||||
@@ -18,9 +19,9 @@
|
||||
* deno test -A test-mirror.ts
|
||||
*/
|
||||
|
||||
import { assert } from "@std/assert";
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCliOrFail } from "./helpers/cli.ts";
|
||||
import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
|
||||
import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
|
||||
|
||||
Deno.test("mirror: storage <-> DB synchronisation", async (t) => {
|
||||
@@ -130,6 +131,10 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => {
|
||||
await Deno.writeTextFile(seedFile, "old content\n");
|
||||
await dbRun("push", seedFile, "test/sync-storage-newer.md");
|
||||
|
||||
// Reflect the shared base into the actual Vault before editing it.
|
||||
await runMirror();
|
||||
assertEquals(await Deno.readTextFile(workDir.join("vault", "test", "sync-storage-newer.md")), "old content\n");
|
||||
|
||||
// Write new content to storage with a timestamp 1 hour in the future
|
||||
const storageFile = workDir.join("vault", "test", "sync-storage-newer.md");
|
||||
await Deno.writeTextFile(storageFile, "new content\n");
|
||||
@@ -138,6 +143,8 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => {
|
||||
await runMirror();
|
||||
|
||||
const resultFile = workDir.join("case4-pull.txt");
|
||||
const info = JSON.parse(await dbRun("info", "test/sync-storage-newer.md"));
|
||||
assertEquals(info.conflicts, "N/A", "An ordinary local edit must not create a conflict");
|
||||
await dbRun("pull", "test/sync-storage-newer.md", resultFile);
|
||||
const storageContent = await Deno.readTextFile(storageFile);
|
||||
const pulledContent = await Deno.readTextFile(resultFile);
|
||||
@@ -184,6 +191,47 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => {
|
||||
assert(pulled === "compat-content\n", `Compatibility mode failed to sync file into DB (got: '${pulled}')`);
|
||||
console.log("[PASS] case 6: compatibility mode works");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Case 7: unknown local origin must preserve both contents regardless of mtime.
|
||||
// This deliberately uses put: push would record a file provenance entry.
|
||||
// -------------------------------------------------------------------
|
||||
await t.step("case 7: unknown local content is preserved, deduplicated, and resolved", async () => {
|
||||
const path = "test/unknown-origin.md";
|
||||
const storageFile = workDir.join("vault", "test", "unknown-origin.md");
|
||||
await runCliWithInputOrFail("original DB content\n", dbDir, "--settings", dbSettings, "put", path);
|
||||
const writeUnknownFile = async () => {
|
||||
await Deno.writeTextFile(storageFile, "unrelated local content\n");
|
||||
await Deno.utime(storageFile, new Date(), new Date(Date.now() + 3600_000));
|
||||
};
|
||||
await writeUnknownFile();
|
||||
await runMirror();
|
||||
|
||||
const info = JSON.parse(await dbRun("info", path));
|
||||
assert(/^1-[\da-f]+$/.test(info.revision), "Expected an independent winning root");
|
||||
assert(/^1-[\da-f]+$/.test(info.conflicts), "Expected exactly one independent conflicting root");
|
||||
assert(info.revision !== info.conflicts, "Expected two distinct revisions");
|
||||
const contents = new Map<string, string>();
|
||||
for (const revision of [info.revision, info.conflicts]) {
|
||||
contents.set(await dbRun("cat-rev", path, revision), revision);
|
||||
}
|
||||
assertEquals([...contents.keys()].sort(), ["original DB content\n", "unrelated local content\n"]);
|
||||
|
||||
// Re-submit identical local bytes even if incoming reflection replaced
|
||||
// the file under writeDocumentsIfConflicted; no third branch is needed.
|
||||
await writeUnknownFile();
|
||||
await runMirror();
|
||||
const repeated = JSON.parse(await dbRun("info", path));
|
||||
assertEquals(repeated.revision, info.revision);
|
||||
assertEquals(repeated.conflicts, info.conflicts);
|
||||
|
||||
const localRevision = contents.get("unrelated local content\n")!;
|
||||
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", dbSettings, "resolve", path, localRevision);
|
||||
const resolved = JSON.parse(await dbRun("info", path));
|
||||
assertEquals(resolved.conflicts, "N/A");
|
||||
assertEquals(resolved.revision, localRevision);
|
||||
assertEquals(await Deno.readTextFile(storageFile), "unrelated local content\n");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { assert } from "@std/assert";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts";
|
||||
import {
|
||||
initSettingsFile,
|
||||
applyP2pSettings,
|
||||
applyP2pTestTweaks,
|
||||
generateSetupUriFromSettings,
|
||||
} from "./helpers/settings.ts";
|
||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||
import {
|
||||
discoverPeer,
|
||||
@@ -9,10 +14,10 @@ import {
|
||||
maybeStartCoturn,
|
||||
stopCoturnIfStarted,
|
||||
} from "./helpers/p2p.ts";
|
||||
import { runCli } from "./helpers/cli.ts";
|
||||
import { runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||
import { getOptimalLoopbackIp } from "./helpers/net.ts";
|
||||
|
||||
Deno.test("p2p-sync: discovers peer and completes sync", async () => {
|
||||
Deno.test("p2p-sync: transfers with the same ID key and rejects a different document ID key", async () => {
|
||||
const loopbackIp = await getOptimalLoopbackIp();
|
||||
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
|
||||
|
||||
@@ -32,14 +37,18 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
|
||||
const hostSettings = workDir.join("settings-host.json");
|
||||
const clientVault = workDir.join("vault-sync");
|
||||
const clientSettings = workDir.join("settings-sync.json");
|
||||
const rejectedVault = workDir.join("vault-rejected");
|
||||
const rejectedSettings = workDir.join("settings-rejected.json");
|
||||
await Deno.mkdir(hostVault, { recursive: true });
|
||||
await Deno.mkdir(clientVault, { recursive: true });
|
||||
await Deno.mkdir(rejectedVault, { recursive: true });
|
||||
|
||||
const relayStarted = await maybeStartLocalRelay(relay);
|
||||
const coturnStarted = await maybeStartCoturn(turnServers);
|
||||
try {
|
||||
await initSettingsFile(hostSettings);
|
||||
await initSettingsFile(clientSettings);
|
||||
await initSettingsFile(rejectedSettings);
|
||||
await applyP2pSettings(
|
||||
hostSettings,
|
||||
roomId,
|
||||
@@ -58,8 +67,52 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
|
||||
"~.*",
|
||||
turnServers
|
||||
);
|
||||
await applyP2pSettings(
|
||||
rejectedSettings,
|
||||
roomId,
|
||||
passphrase,
|
||||
"self-hosted-livesync-cli-tests",
|
||||
relay,
|
||||
"~.*",
|
||||
turnServers
|
||||
);
|
||||
await applyP2pTestTweaks(hostSettings, hostPeerName, passphrase);
|
||||
await applyP2pTestTweaks(clientSettings, clientPeerName, passphrase);
|
||||
await applyP2pTestTweaks(rejectedSettings, "p2p-rejected-" + nonce, passphrase);
|
||||
for (const [vault, path, key, label] of [
|
||||
[hostVault, hostSettings, "ab".repeat(32), "host"],
|
||||
[clientVault, clientSettings, "ab".repeat(32), "client"],
|
||||
[rejectedVault, rejectedSettings, "cd".repeat(32), "rejected"],
|
||||
]) {
|
||||
const settings = JSON.parse(await Deno.readTextFile(path));
|
||||
settings.idDerivationVersion = 1;
|
||||
settings.idDerivationKey = key;
|
||||
const sourcePath = workDir.join("setup-source-" + label + ".json");
|
||||
await Deno.writeTextFile(sourcePath, JSON.stringify(settings));
|
||||
const setupPassphrase = "independent-id-setup-passphrase";
|
||||
const setupUri = await generateSetupUriFromSettings(sourcePath, setupPassphrase, true);
|
||||
await runCliWithInputOrFail(setupPassphrase + "\n", vault, "--settings", path, "setup", setupUri);
|
||||
const persisted = JSON.parse(await Deno.readTextFile(path));
|
||||
assert(persisted.idDerivationVersion === 1, "The Setup URI lost the ID derivation version.");
|
||||
assert(persisted.idDerivationKey === "", "The CLI stored the ID key in plain text.");
|
||||
assert(
|
||||
typeof persisted.encryptedIdDerivationKey === "string" && persisted.encryptedIdDerivationKey.length > 0,
|
||||
"The CLI did not encrypt the saved ID key."
|
||||
);
|
||||
assert(persisted.P2P_Enabled === true, "The Setup URI disabled P2P.");
|
||||
assert(persisted.P2P_roomID === roomId, "The Setup URI changed the P2P room.");
|
||||
assert(persisted.P2P_relays === relay, "The Setup URI changed the P2P relay.");
|
||||
assert(persisted.remoteType === "ONLY_P2P", "The Setup URI changed the remote type.");
|
||||
}
|
||||
const notePath = "p2p/independent-id-note.md";
|
||||
await runCliWithInputOrFail(
|
||||
"A note transferred with the saved ID key.\n",
|
||||
clientVault,
|
||||
"--settings",
|
||||
clientSettings,
|
||||
"put",
|
||||
notePath
|
||||
);
|
||||
|
||||
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
|
||||
try {
|
||||
@@ -82,9 +135,32 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
|
||||
syncResult.code === 0,
|
||||
`p2p-sync failed\nstdout: ${syncResult.stdout}\nstderr: ${syncResult.stderr}`
|
||||
);
|
||||
const rejectedPeer = await discoverPeer(rejectedVault, rejectedSettings, peersTimeout, hostPeerName);
|
||||
const rejectedSync = await runCli(
|
||||
rejectedVault,
|
||||
"--settings",
|
||||
rejectedSettings,
|
||||
"p2p-sync",
|
||||
rejectedPeer.id,
|
||||
String(syncTimeout)
|
||||
);
|
||||
assert(
|
||||
rejectedSync.code !== 0,
|
||||
`P2P accepted a different key for obfuscated document IDs.\nstdout: ${rejectedSync.stdout}\nstderr: ${rejectedSync.stderr}`
|
||||
);
|
||||
assert(
|
||||
rejectedSync.combined.includes("Tweak values are not matched"),
|
||||
`P2P failed before checking peer settings.\nstdout: ${rejectedSync.stdout}\nstderr: ${rejectedSync.stderr}`
|
||||
);
|
||||
} finally {
|
||||
await host.stop();
|
||||
}
|
||||
const received = sanitiseCatStdout(
|
||||
await runCliOrFail(hostVault, "--settings", hostSettings, "cat", notePath)
|
||||
).trimEnd();
|
||||
assert(received === "A note transferred with the saved ID key.", "The host did not receive the keyed note.");
|
||||
const rejectedRead = await runCli(rejectedVault, "--settings", rejectedSettings, "cat", notePath);
|
||||
assert(rejectedRead.code !== 0, "The rejected device received the keyed note.");
|
||||
} finally {
|
||||
await stopLocalRelayIfStarted(relayStarted);
|
||||
await stopCoturnIfStarted(coturnStarted);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
|
||||
/** Browser runtime for Self-hosted LiveSync over the File System Access API. */
|
||||
|
||||
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
@@ -217,7 +218,9 @@ export class WebAppRuntime {
|
||||
useRedFlagFeatures(core);
|
||||
useCheckRemoteSize(core);
|
||||
useRemoteConfiguration(core);
|
||||
this.p2p = useP2PReplicatorFeature(core);
|
||||
this.p2p = useP2PReplicatorFeature(core, undefined, undefined, {
|
||||
prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)),
|
||||
});
|
||||
this.paneHost = {
|
||||
services: core.services,
|
||||
p2p: this.p2p,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.28-webapp",
|
||||
"version": "1.0.32-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.28-webpeer",
|
||||
"version": "1.0.32-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
|
||||
import { type P2PSyncSetting, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
@@ -70,9 +71,8 @@ export class WebPeerRuntime {
|
||||
isScheduled: () => this.restartScheduled,
|
||||
},
|
||||
});
|
||||
this.p2p = useP2PReplicatorFeature({
|
||||
services: this.services,
|
||||
serviceModules: {},
|
||||
this.p2p = useP2PReplicatorFeature({ services: this.services, serviceModules: {} }, undefined, undefined, {
|
||||
prepareP2PSettings: useP2PSettingsPreparation(this.services.API.webCompatFetch.bind(this.services.API)),
|
||||
});
|
||||
this.p2pLogCollector = new P2PLogCollector(this.events);
|
||||
this.paneHost = {
|
||||
|
||||
@@ -7,6 +7,26 @@
|
||||
* remove it from this map in the same change.
|
||||
*/
|
||||
export const liveSyncProvisionalEnglishMessages = {
|
||||
"Configure TURN when a direct connection cannot be established or when you select TURN relay only.":
|
||||
"Configure TURN when a direct connection cannot be established or when you select TURN relay only.",
|
||||
"TURN configuration": "TURN configuration",
|
||||
Manual: "Manual",
|
||||
"Managed (Cloudflare)": "Managed (Cloudflare)",
|
||||
"TURN Key ID": "TURN Key ID",
|
||||
"TURN Key API Token": "TURN Key API Token",
|
||||
"Unsupported TURN configuration": "Unsupported TURN configuration",
|
||||
"The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.":
|
||||
"The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.",
|
||||
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings.":
|
||||
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings.",
|
||||
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.":
|
||||
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
|
||||
"Enter a TURN Key ID.": "Enter a TURN Key ID.",
|
||||
"TURN Key ID contains unsupported characters.": "TURN Key ID contains unsupported characters.",
|
||||
"Enter a TURN Key API Token.": "Enter a TURN Key API Token.",
|
||||
"TURN Key API Token must use Bearer token syntax.": "TURN Key API Token must use Bearer token syntax.",
|
||||
"The selected TURN configuration is not supported.": "The selected TURN configuration is not supported.",
|
||||
|
||||
"Setup Complete: Preparing to Fetch from Another Device": "Setup Complete: Preparing to Fetch from Another Device",
|
||||
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.":
|
||||
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.",
|
||||
@@ -28,8 +48,8 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay.",
|
||||
"Learn more about P2P connections": "Learn more about P2P connections",
|
||||
"Learn more about signalling and TURN": "Learn more about signalling and TURN",
|
||||
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.":
|
||||
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.",
|
||||
"WebRTC encrypts data between your devices, including when it passes through TURN. The TURN provider cannot read the transferred data. It can see network addresses and traffic volume.":
|
||||
"WebRTC encrypts data between your devices, including when it passes through TURN. The TURN provider cannot read the transferred data. It can see network addresses and traffic volume.",
|
||||
"Connection compatibility": "Connection compatibility",
|
||||
"P2P message size": "P2P message size",
|
||||
Standard: "Standard",
|
||||
@@ -184,6 +204,50 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Repair failed before the source was removed. Run inspection again before retrying.",
|
||||
"Connection settings": "Connection settings",
|
||||
"Saved connections": "Saved connections",
|
||||
"ID generation": "ID generation",
|
||||
"Keep current configuration": "Keep current configuration",
|
||||
"Set an ID key": "Set an ID key",
|
||||
"Current configuration: a saved ID key is used.": "Current configuration: a saved ID key is used.",
|
||||
"Current configuration: the saved ID key is retained while E2EE is off.":
|
||||
"Current configuration: the saved ID key is retained while E2EE is off.",
|
||||
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase.":
|
||||
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase.",
|
||||
"Changing the E2EE passphrase changes IDs generated by the legacy configuration.":
|
||||
"Changing the E2EE passphrase changes IDs generated by the legacy configuration.",
|
||||
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically.":
|
||||
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically.",
|
||||
Configured: "Configured",
|
||||
"The saved ID key is configured. Its source cannot be shown again.":
|
||||
"The saved ID key is configured. Its source cannot be shown again.",
|
||||
"Leave this input empty to keep the saved ID key.": "Leave this input empty to keep the saved ID key.",
|
||||
"Generate a random ID key": "Generate a random ID key",
|
||||
"How to set the ID key": "How to set the ID key",
|
||||
"Derive from current E2EE passphrase": "Derive from current E2EE passphrase",
|
||||
"Enter an ID source": "Enter an ID source",
|
||||
"Import an ID recovery code": "Import an ID recovery code",
|
||||
"ID source": "ID source",
|
||||
"ID recovery code": "ID recovery code",
|
||||
"Enter an ID recovery code": "Enter an ID recovery code",
|
||||
"Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.":
|
||||
"Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.",
|
||||
"Paste a tagged recovery code from an existing device to restore the same ID key.":
|
||||
"Paste a tagged recovery code from an existing device to restore the same ID key.",
|
||||
"For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.":
|
||||
"For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.",
|
||||
"Show current recovery code": "Show current recovery code",
|
||||
"Hide current recovery code": "Hide current recovery code",
|
||||
"Current ID recovery code": "Current ID recovery code",
|
||||
"Copy recovery code": "Copy recovery code",
|
||||
"Recovery code copied.": "Recovery code copied.",
|
||||
"The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.":
|
||||
"The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.",
|
||||
"The recovery code could not be copied. Select and copy the visible code instead.":
|
||||
"The recovery code could not be copied. Select and copy the visible code instead.",
|
||||
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead.":
|
||||
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead.",
|
||||
"An ID source is required to enable this option.": "An ID source is required to enable this option.",
|
||||
"The ID source or recovery code is invalid. Check it and try again.":
|
||||
"The ID source or recovery code is invalid. Check it and try again.",
|
||||
} as const;
|
||||
|
||||
export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { usesEncryptedInternalMetadata } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
type EndpointProjection = readonly [kind: "url" | "invalid-url", value: string];
|
||||
|
||||
@@ -42,7 +43,10 @@ function projectHeaders(value: string): readonly (readonly [name: string, value:
|
||||
}
|
||||
|
||||
function projectRemoteSecurity(settings: RemoteDBSettings) {
|
||||
return settings.encrypt
|
||||
return [
|
||||
settings.idDerivationVersion,
|
||||
settings.idDerivationKey,
|
||||
settings.encrypt
|
||||
? ([
|
||||
"encrypted",
|
||||
settings.passphrase,
|
||||
@@ -50,7 +54,8 @@ function projectRemoteSecurity(settings: RemoteDBSettings) {
|
||||
settings.E2EEAlgorithm,
|
||||
settings.permitEmptyPassphrase,
|
||||
] as const)
|
||||
: (["plain"] as const);
|
||||
: (["plain"] as const),
|
||||
] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +82,7 @@ export function getCouchDBReplicatorConfigurationIdentity(settings: RemoteDBSett
|
||||
settings.useRequestAPI,
|
||||
settings.disableRequestURI,
|
||||
projectRemoteSecurity(settings),
|
||||
usesEncryptedInternalMetadata(settings),
|
||||
settings.enableCompression,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,17 @@ describe("active Replicator configuration identity", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("replaces a connection when the independent ID key changes", () => {
|
||||
const first = configuredSettings({ idDerivationVersion: 1, idDerivationKey: "a".repeat(64) });
|
||||
const second = { ...first, idDerivationKey: "b".repeat(64) };
|
||||
expect(getCouchDBReplicatorConfigurationIdentity(second)).not.toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(first)
|
||||
);
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity(second)).not.toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(first)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["couchDB_URI", "https://other.example.test/base"],
|
||||
["couchDB_DBNAME", "other-vault"],
|
||||
@@ -67,6 +78,22 @@ describe("active Replicator configuration identity", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("recreates the CouchDB connection when internal Metadata encryption becomes effective", () => {
|
||||
const active = configuredSettings({ usePathObfuscation: true, encryptInternalMetadata: false });
|
||||
const enabled = { ...active, encryptInternalMetadata: true };
|
||||
|
||||
expect(getCouchDBReplicatorConfigurationIdentity(enabled)).not.toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(active)
|
||||
);
|
||||
const inactive = { ...active, usePathObfuscation: false };
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...inactive, encryptInternalMetadata: true })).toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(inactive)
|
||||
);
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity(enabled)).toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(active)
|
||||
);
|
||||
});
|
||||
|
||||
it("projects only the active CouchDB authentication mode", () => {
|
||||
const basic = configuredSettings({ useJWT: false, jwtKey: "inactive-a" });
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...basic, jwtKey: "inactive-b" })).toBe(
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CAPABILITY_NOT_APPLICABLE,
|
||||
CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
NO_INTERACTION,
|
||||
PROVIDER_OWNED_CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
defineReplicatorProviderDefinitions,
|
||||
@@ -134,7 +135,7 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
[REMOTE_MINIO]: {
|
||||
kind: REMOTE_MINIO,
|
||||
diagnosticName: "Object Storage",
|
||||
readiness: CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
readiness: PROVIDER_OWNED_CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
isConfigured: (settings) =>
|
||||
settings.remoteType === REMOTE_MINIO && !!settings.endpoint?.trim() && !!settings.bucket?.trim(),
|
||||
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
|
||||
|
||||
@@ -47,6 +47,12 @@ describe("central Replicator provider definitions", () => {
|
||||
.toEqual(["connection", "preferred-tweak", "security-seed", "synchronisation-information"].sort());
|
||||
});
|
||||
|
||||
it("lets Journal prepare its own fresh Security Seed while CouchDB uses central preparation", () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
expect(definitions.get(REMOTE_COUCHDB)?.readiness.centralRemotePreparation).toBe("required");
|
||||
expect(definitions.get(REMOTE_MINIO)?.readiness.centralRemotePreparation).toBe("provider-owned");
|
||||
});
|
||||
|
||||
it("composes CouchDB and Object Storage policies outside LiveSyncBaseCore", async () => {
|
||||
const host = {} as Parameters<typeof createCentralReplicatorProviderDefinitions>[0];
|
||||
const definitions = createCentralReplicatorProviderDefinitions(host);
|
||||
|
||||
@@ -285,6 +285,46 @@ describe("replicator probe factories", () => {
|
||||
expect(objectReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reads the Security Seed once per resource, including concurrent reads, and refreshes for a new resource", async () => {
|
||||
const settings = createSettings();
|
||||
const factory = createCouchDBSecuritySeedResourceFactory({} as never);
|
||||
const firstResource = await factory(settings);
|
||||
const firstReplicator = mocks.couchDB[0];
|
||||
const firstSeed = new Uint8Array([1]);
|
||||
firstReplicator.getReplicationPBKDF2Salt.mockResolvedValue(firstSeed);
|
||||
|
||||
const [first, concurrent] = await Promise.all([firstResource.read(), firstResource.read()]);
|
||||
expect(first).toBe(firstSeed);
|
||||
expect(concurrent).toBe(firstSeed);
|
||||
await expect(firstResource.read()).resolves.toBe(firstSeed);
|
||||
expect(firstReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledOnce();
|
||||
expect(firstReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledWith({ ...settings }, true);
|
||||
await firstResource.dispose();
|
||||
|
||||
const nextResource = await factory(settings);
|
||||
const nextReplicator = mocks.couchDB[1];
|
||||
const nextSeed = new Uint8Array([2]);
|
||||
nextReplicator.getReplicationPBKDF2Salt.mockResolvedValue(nextSeed);
|
||||
await expect(nextResource.read()).resolves.toBe(nextSeed);
|
||||
expect(nextReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledOnce();
|
||||
expect(nextReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledWith({ ...settings }, true);
|
||||
await nextResource.dispose();
|
||||
});
|
||||
|
||||
it("retries a failed Security Seed read within the same resource", async () => {
|
||||
const resource = await createCouchDBSecuritySeedResourceFactory({} as never)(createSettings());
|
||||
const replicator = mocks.couchDB[0];
|
||||
const failure = new Error("connection interrupted");
|
||||
const seed = new Uint8Array([1]);
|
||||
replicator.getReplicationPBKDF2Salt.mockRejectedValueOnce(failure).mockResolvedValueOnce(seed);
|
||||
|
||||
await expect(resource.read()).rejects.toBe(failure);
|
||||
await expect(resource.read()).resolves.toBe(seed);
|
||||
await expect(resource.read()).resolves.toBe(seed);
|
||||
expect(replicator.getReplicationPBKDF2Salt).toHaveBeenCalledTimes(2);
|
||||
await resource.dispose();
|
||||
});
|
||||
|
||||
it("checks synchronisation information through an owned connection and disposes the private Replicator", async () => {
|
||||
const settings = createSettings();
|
||||
const snapshot = { ...settings };
|
||||
|
||||
@@ -21,8 +21,19 @@ function createSecuritySeedResourceFactory(
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
const replicator = createReplicator();
|
||||
let readPromise: Promise<Uint8Array<ArrayBuffer>> | undefined;
|
||||
const read = () => {
|
||||
if (readPromise) return readPromise;
|
||||
const pending = Promise.resolve().then(() => replicator.getReplicationPBKDF2Salt(snapshot, true));
|
||||
readPromise = pending;
|
||||
// A failed read must not poison a later retry within the same resource.
|
||||
void pending.catch(() => {
|
||||
if (readPromise === pending) readPromise = undefined;
|
||||
});
|
||||
return pending;
|
||||
};
|
||||
return Promise.resolve({
|
||||
read: () => replicator.getReplicationPBKDF2Salt(snapshot, true),
|
||||
read,
|
||||
dispose: createReplicatorDisposer(replicator),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { redactTurnSettingsForReport } from "./turnSettingsPrivacy";
|
||||
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/replication/httplib";
|
||||
@@ -67,6 +68,7 @@ export async function generateReport(settings: ObsidianLiveSyncSettings, core: L
|
||||
delete pluginConfig[key as keyof ObsidianLiveSyncSettings];
|
||||
}
|
||||
|
||||
redactTurnSettingsForReport(pluginConfig);
|
||||
pluginConfig.couchDB_DBNAME = REDACTED;
|
||||
pluginConfig.couchDB_PASSWORD = REDACTED;
|
||||
const scheme = pluginConfig.couchDB_URI.startsWith("http:")
|
||||
@@ -78,6 +80,8 @@ export async function generateReport(settings: ObsidianLiveSyncSettings, core: L
|
||||
pluginConfig.couchDB_USER = REDACTED;
|
||||
pluginConfig.passphrase = REDACTED;
|
||||
pluginConfig.encryptedPassphrase = REDACTED;
|
||||
pluginConfig.idDerivationKey = REDACTED;
|
||||
pluginConfig.encryptedIdDerivationKey = REDACTED;
|
||||
pluginConfig.encryptedCouchDBConnection = REDACTED;
|
||||
pluginConfig.accessKey = REDACTED;
|
||||
pluginConfig.secretKey = REDACTED;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { generateReport } from "./reportTool";
|
||||
|
||||
vi.mock("./utils", () => ({ requestToCouchDBWithCredentials: vi.fn() }));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
|
||||
compatGlobal: { origin: "test", navigator: { userAgent: "test" } },
|
||||
}));
|
||||
|
||||
describe("TURN credentials in diagnostic reports", () => {
|
||||
it("redacts the derived ID key and its encrypted local wrapper", async () => {
|
||||
const key = "f3205cc41d24116d8c2484993c9d9a2e667373af338ba02f2ee71199adb82f2e";
|
||||
const wrapper = "encrypted-id-key-test-wrapper";
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
idDerivationVersion: 1 as const,
|
||||
idDerivationKey: key,
|
||||
encryptedIdDerivationKey: wrapper,
|
||||
};
|
||||
const core = { services: { vault: { isStorageInsensitive: () => false } } } as unknown as LiveSyncBaseCore;
|
||||
const report = await generateReport(settings, core);
|
||||
const text = JSON.stringify(report);
|
||||
expect(text).not.toContain(key);
|
||||
expect(text).not.toContain(wrapper);
|
||||
});
|
||||
|
||||
it("redacts provider tokens in all profiles and runtime credentials", async () => {
|
||||
const token = "private+token/with=symbols";
|
||||
const provider = { P2P_managedType: "CF", P2P_managedId: "private-key", P2P_managedToken: token };
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_P2P,
|
||||
...provider,
|
||||
P2P_iceServers: [{ urls: "turn:example.test", username: "issued-user", credential: "issued-password" }],
|
||||
P2P_iceServersExpiresAt: 123456789,
|
||||
remoteConfigurations: {
|
||||
inactive: {
|
||||
id: "inactive",
|
||||
name: "Inactive TURN",
|
||||
isEncrypted: false,
|
||||
uri: `sls+p2p://room?managedType=CF&managedId=private-key&token=${encodeURIComponent(token)}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
const core = { services: { vault: { isStorageInsensitive: () => false } } } as unknown as LiveSyncBaseCore;
|
||||
const report = await generateReport(settings, core);
|
||||
const text = JSON.stringify(report);
|
||||
expect(text).not.toContain(token);
|
||||
expect(text).not.toContain(encodeURIComponent(token));
|
||||
expect(text).not.toContain("private-key");
|
||||
expect(report.pluginConfig.remoteConfigurations.inactive.uri).toBe("sls+p2p://");
|
||||
expect(settings.P2P_managedToken).toBe(token);
|
||||
expect(text).not.toMatch(/issued-user|issued-password|P2P_iceServers/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
hasManagedP2PTurnConfiguration,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { pickP2PSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { CLOUDFLARE_TURN_TYPE } from "@/integrations/cloudflare/settings";
|
||||
|
||||
/** Include inactive profiles when deciding whether Markdown would disclose provider settings. */
|
||||
export function hasManagedTurnSettings(settings: Partial<ObsidianLiveSyncSettings>): boolean {
|
||||
return (
|
||||
hasManagedP2PTurnConfiguration(settings) ||
|
||||
Object.values(settings.remoteConfigurations ?? {}).some(({ uri }) => {
|
||||
if (!uri.startsWith("sls+p2p://")) return false;
|
||||
const queryStart = uri.indexOf("?");
|
||||
return (
|
||||
queryStart >= 0 && new URLSearchParams(uri.slice(queryStart + 1).split("#", 1)[0]).has("managedType")
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Reports retain a recognised provider label and omit issued credentials. */
|
||||
export function redactTurnSettingsForReport(settings: Partial<ObsidianLiveSyncSettings>): void {
|
||||
if (settings.P2P_managedType) {
|
||||
settings.P2P_managedType =
|
||||
settings.P2P_managedType === CLOUDFLARE_TURN_TYPE ? CLOUDFLARE_TURN_TYPE : "redacted";
|
||||
}
|
||||
if (settings.P2P_managedId !== undefined) settings.P2P_managedId = "redacted";
|
||||
if (settings.P2P_managedToken !== undefined) settings.P2P_managedToken = "redacted";
|
||||
delete settings.P2P_iceServers;
|
||||
delete settings.P2P_iceServersExpiresAt;
|
||||
}
|
||||
|
||||
/** Managed connection profiles are shared through Setup URIs and QR codes. */
|
||||
export function omitManagedTurnProfilesFromMarkdown(settings: Partial<ObsidianLiveSyncSettings>): void {
|
||||
delete settings.P2P_iceServers;
|
||||
delete settings.P2P_iceServersExpiresAt;
|
||||
if (!hasManagedTurnSettings(settings)) return;
|
||||
delete settings.P2P_managedType;
|
||||
delete settings.P2P_managedId;
|
||||
delete settings.P2P_managedToken;
|
||||
delete settings.remoteConfigurations;
|
||||
delete settings.activeConfigurationId;
|
||||
delete settings.P2P_ActiveRemoteConfigurationId;
|
||||
}
|
||||
|
||||
/** Preserve the complete connection when Markdown omits its profile group. */
|
||||
export function preserveManagedTurnProfilesOnMarkdownImport(
|
||||
incoming: Partial<ObsidianLiveSyncSettings>,
|
||||
current: ObsidianLiveSyncSettings,
|
||||
merged: ObsidianLiveSyncSettings
|
||||
): void {
|
||||
if (
|
||||
!hasManagedTurnSettings(current) ||
|
||||
incoming.remoteConfigurations !== undefined ||
|
||||
incoming.P2P_managedType !== undefined
|
||||
)
|
||||
return;
|
||||
merged.remoteConfigurations = structuredClone(current.remoteConfigurations);
|
||||
merged.activeConfigurationId = current.activeConfigurationId;
|
||||
merged.P2P_ActiveRemoteConfigurationId = current.P2P_ActiveRemoteConfigurationId;
|
||||
Object.assign(merged, pickP2PSyncSettings(current));
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_P2P,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
SettingService,
|
||||
type SettingServiceDependencies,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import {
|
||||
hasManagedTurnSettings,
|
||||
omitManagedTurnProfilesFromMarkdown,
|
||||
preserveManagedTurnProfilesOnMarkdownImport,
|
||||
redactTurnSettingsForReport,
|
||||
} from "./turnSettingsPrivacy";
|
||||
|
||||
class MemorySettingService extends SettingService {
|
||||
readonly items = new Map<string, string>();
|
||||
saved?: ObsidianLiveSyncSettings;
|
||||
protected setItem(key: string, value: string) {
|
||||
this.items.set(key, value);
|
||||
}
|
||||
protected getItem(key: string) {
|
||||
return this.items.get(key) ?? "";
|
||||
}
|
||||
protected deleteItem(key: string) {
|
||||
this.items.delete(key);
|
||||
}
|
||||
protected saveData(settings: ObsidianLiveSyncSettings) {
|
||||
this.saved = structuredClone(settings);
|
||||
return Promise.resolve();
|
||||
}
|
||||
protected loadData() {
|
||||
return Promise.resolve(this.saved);
|
||||
}
|
||||
}
|
||||
|
||||
function configuredSettings() {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
P2P_managedType: "CF",
|
||||
P2P_managedId: "private-key-id",
|
||||
P2P_managedToken: "private-token",
|
||||
remoteConfigurations: {
|
||||
managed: {
|
||||
id: "managed",
|
||||
name: "Managed TURN",
|
||||
isEncrypted: false,
|
||||
uri: "sls+p2p://room?managedType=CF&managedId=private-key-id&token=private-token",
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "central",
|
||||
P2P_ActiveRemoteConfigurationId: "managed",
|
||||
};
|
||||
}
|
||||
|
||||
describe("managed TURN settings privacy", () => {
|
||||
it("preserves the active managed room through Markdown import, save, and reload", async () => {
|
||||
const current = {
|
||||
...configuredSettings(),
|
||||
remoteType: REMOTE_P2P,
|
||||
activeConfigurationId: "managed",
|
||||
P2P_roomID: "local-room",
|
||||
P2P_relays: "wss://local-relay.example.test",
|
||||
P2P_passphrase: "local-passphrase",
|
||||
};
|
||||
const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current });
|
||||
current.remoteConfigurations.managed.uri = originalURI;
|
||||
const service = new MemorySettingService(new ServiceContext(), {
|
||||
APIService: {
|
||||
getSystemVaultName: () => "test-vault",
|
||||
getAppID: () => "test-app",
|
||||
addLog: () => undefined,
|
||||
confirm: { askString: async () => "" },
|
||||
} as unknown as SettingServiceDependencies["APIService"],
|
||||
});
|
||||
service.settings = structuredClone(current);
|
||||
const incoming: Partial<ObsidianLiveSyncSettings> = {
|
||||
P2P_roomID: "imported-room",
|
||||
P2P_relays: "wss://imported-relay.example.test",
|
||||
P2P_passphrase: "imported-passphrase",
|
||||
};
|
||||
const merged = { ...structuredClone(DEFAULT_SETTINGS), ...incoming };
|
||||
preserveManagedTurnProfilesOnMarkdownImport(incoming, current, merged);
|
||||
await service.applyExternalSettings(merged, true);
|
||||
const saved = service.saved!.remoteConfigurations.managed;
|
||||
const uri = saved.isEncrypted ? await service.decryptConfigurationItem(saved.uri, "*") : saved.uri;
|
||||
expect(uri).toBe(originalURI);
|
||||
expect(service.settings.P2P_roomID).toBe("local-room");
|
||||
await service.loadSettings();
|
||||
expect(service.settings.P2P_roomID).toBe("local-room");
|
||||
});
|
||||
|
||||
it("redacts provider fields and issued credentials, including unknown integrations", () => {
|
||||
const settings = configuredSettings();
|
||||
settings.P2P_managedType = "private-token";
|
||||
redactTurnSettingsForReport(settings);
|
||||
expect([settings.P2P_managedType, settings.P2P_managedId, settings.P2P_managedToken]).toEqual([
|
||||
"redacted",
|
||||
"redacted",
|
||||
"redacted",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the whole managed profile group from Markdown, including inactive sources", () => {
|
||||
const settings = configuredSettings();
|
||||
settings.P2P_managedType = "";
|
||||
expect(hasManagedTurnSettings(settings)).toBe(true);
|
||||
omitManagedTurnProfilesFromMarkdown(settings);
|
||||
expect(JSON.stringify(settings)).not.toMatch(/private-token|private-key-id|sls\+p2p/);
|
||||
expect(settings).not.toHaveProperty("remoteConfigurations");
|
||||
expect(settings).not.toHaveProperty("activeConfigurationId");
|
||||
expect(settings).not.toHaveProperty("P2P_ActiveRemoteConfigurationId");
|
||||
});
|
||||
|
||||
it("preserves existing profiles and both selections when Markdown omits the group", () => {
|
||||
const current = configuredSettings();
|
||||
const incoming = { ...DEFAULT_SETTINGS };
|
||||
delete (incoming as Partial<typeof incoming>).remoteConfigurations;
|
||||
delete (incoming as Partial<typeof incoming>).P2P_managedType;
|
||||
const merged = { ...DEFAULT_SETTINGS, ...incoming };
|
||||
preserveManagedTurnProfilesOnMarkdownImport(incoming, current, merged);
|
||||
expect(merged.remoteConfigurations).toEqual(current.remoteConfigurations);
|
||||
expect(merged.remoteConfigurations).not.toBe(current.remoteConfigurations);
|
||||
expect(merged.P2P_managedToken).toEqual(current.P2P_managedToken);
|
||||
expect(merged.activeConfigurationId).toBe("central");
|
||||
expect(merged.P2P_ActiveRemoteConfigurationId).toBe("managed");
|
||||
});
|
||||
|
||||
it("retains the manual-only Markdown contract", () => {
|
||||
const settings = { ...DEFAULT_SETTINGS };
|
||||
const before = structuredClone(settings);
|
||||
omitManagedTurnProfilesFromMarkdown(settings);
|
||||
expect(settings).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
normalizePath: vi.fn((path: string) => `normalised(${path})`),
|
||||
path2idBase: vi.fn(async (path: string) => path),
|
||||
id2pathBase: vi.fn((path: string) => path),
|
||||
expandFilePathPrefix: vi.fn((path: string): [string, string] => {
|
||||
if (path.startsWith("i:")) return ["i:", path.substring(2)];
|
||||
return ["", path];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
normalizePath: mocks.normalizePath,
|
||||
Platform: {},
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/string_and_binary/path", () => ({
|
||||
path2id_base: mocks.path2idBase,
|
||||
id2path_base: mocks.id2pathBase,
|
||||
expandFilePathPrefix: mocks.expandFilePathPrefix,
|
||||
isValidFilenameInLinux: vi.fn(),
|
||||
isValidFilenameInDarwin: vi.fn(),
|
||||
isValidFilenameInWidows: vi.fn(),
|
||||
isValidFilenameInAndroid: vi.fn(),
|
||||
stripAllPrefixes: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("path ID normalisation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Folder/Note.md", "", "Folder/Note.md"],
|
||||
["Folder/Poem: Example.md", "", "Folder/Poem: Example.md"],
|
||||
["Folder/Poem: Example: Final Draft.md", "", "Folder/Poem: Example: Final Draft.md"],
|
||||
["i:Folder/Poem: Example.md", "i:", "Folder/Poem: Example.md"],
|
||||
])("normalises the complete path body for %s", async (filename, prefix, body) => {
|
||||
const { path2id } = await import("./utils.ts");
|
||||
|
||||
const result = await path2id(filename as never, false, false);
|
||||
|
||||
expect(mocks.normalizePath).toHaveBeenCalledWith(body);
|
||||
expect(mocks.path2idBase).toHaveBeenCalledWith(`${prefix}normalised(${body})`, false, false);
|
||||
expect(result).toBe(`${prefix}normalised(${body})`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Folder/Note.md", "", "Folder/Note.md"],
|
||||
["Folder/Poem: Example.md", "", "Folder/Poem: Example.md"],
|
||||
["Folder/Poem: Example: Final Draft.md", "", "Folder/Poem: Example: Final Draft.md"],
|
||||
["i:Folder/Poem: Example.md", "i:", "Folder/Poem: Example.md"],
|
||||
])("preserves the path namespace while normalising %s", async (filename, prefix, body) => {
|
||||
mocks.id2pathBase.mockReturnValue(filename);
|
||||
const { id2path } = await import("./utils.ts");
|
||||
|
||||
const result = id2path(filename as never);
|
||||
|
||||
expect(mocks.normalizePath).toHaveBeenCalledWith(body);
|
||||
expect(result).toBe(`${prefix}normalised(${body})`);
|
||||
});
|
||||
});
|
||||
+5
-8
@@ -7,6 +7,7 @@ import {
|
||||
isValidFilenameInWidows,
|
||||
isValidFilenameInAndroid,
|
||||
stripAllPrefixes,
|
||||
expandFilePathPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
@@ -41,22 +42,18 @@ export async function path2id(
|
||||
obfuscatePassphrase: string | false,
|
||||
caseInsensitive: boolean
|
||||
): Promise<DocumentID> {
|
||||
const temp = filename.split(":");
|
||||
const path = temp.pop();
|
||||
const [prefix, path] = expandFilePathPrefix(filename);
|
||||
const normalizedPath = normalizePath(path as FilePath);
|
||||
temp.push(normalizedPath);
|
||||
const fixedPath = temp.join(":") as FilePathWithPrefix;
|
||||
const fixedPath = `${prefix}${normalizedPath}` as FilePathWithPrefix;
|
||||
|
||||
const out = await path2id_base(fixedPath, obfuscatePassphrase, caseInsensitive);
|
||||
return out;
|
||||
}
|
||||
export function id2path(id: DocumentID, entry?: EntryHasPath): FilePathWithPrefix {
|
||||
const filename = id2path_base(id, entry);
|
||||
const temp = filename.split(":");
|
||||
const path = temp.pop();
|
||||
const [prefix, path] = expandFilePathPrefix(filename);
|
||||
const normalizedPath = normalizePath(path as FilePath);
|
||||
temp.push(normalizedPath);
|
||||
const fixedPath = temp.join(":") as FilePathWithPrefix;
|
||||
const fixedPath = `${prefix}${normalizedPath}` as FilePathWithPrefix;
|
||||
return fixedPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import type { P2PConnectionInfo } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { CLOUDFLARE_TURN_TYPE } from "@/integrations/cloudflare/settings";
|
||||
import { validateManagedTurnSettings } from "@/integrations/turnSettings";
|
||||
import { translateLiveSyncMessage as translate, translateIfAvailable } from "@/common/translation";
|
||||
|
||||
type TurnSettings = Pick<P2PConnectionInfo, "P2P_turnServers" | "P2P_turnUsername" | "P2P_turnCredential" | "P2P_managedType" | "P2P_managedId" | "P2P_managedToken">;
|
||||
let { settings = $bindable() }: { settings: TurnSettings } = $props();
|
||||
const managedType = $derived(settings.P2P_managedType ?? "");
|
||||
const error = $derived(validateManagedTurnSettings(settings));
|
||||
|
||||
function selectProvider(type: string) {
|
||||
settings.P2P_managedType = type || undefined;
|
||||
settings.P2P_managedId = type ? "" : undefined;
|
||||
settings.P2P_managedToken = type ? "" : undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="turn-configuration">
|
||||
<label>
|
||||
<span>{translate("TURN configuration")}</span>
|
||||
<select aria-label={translate("TURN configuration")} name="p2p-turn-source" value={managedType} onchange={(event) => selectProvider(event.currentTarget.value)}>
|
||||
<option value="">{translate("Manual")}</option>
|
||||
<option value={CLOUDFLARE_TURN_TYPE}>{translate("Managed (Cloudflare)")}</option>
|
||||
{#if managedType !== "" && managedType !== CLOUDFLARE_TURN_TYPE}
|
||||
<option value={managedType} disabled>{translate("Unsupported TURN configuration")}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
{#if managedType === ""}
|
||||
<label>
|
||||
<span>{translate("TURN Server URLs (comma-separated)")}</span>
|
||||
<textarea name="p2p-turn-servers" rows="3" placeholder="turn:turn.example.com:3478"
|
||||
bind:value={settings.P2P_turnServers} autocapitalize="off" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>{translate("TURN Username")}</span>
|
||||
<input type="text" name="p2p-turn-username" placeholder={translate("Enter TURN username")} bind:value={settings.P2P_turnUsername}
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{translate("TURN Credential")}</span>
|
||||
<input type="password" name="p2p-turn-credential" placeholder={translate("Enter TURN credential")} bind:value={settings.P2P_turnCredential}
|
||||
autocomplete="new-password" />
|
||||
</label>
|
||||
{:else if managedType === CLOUDFLARE_TURN_TYPE}
|
||||
<label>
|
||||
<span>{translate("TURN Key ID")}</span>
|
||||
<input type="text" name="p2p-turn-turnKeyId" bind:value={settings.P2P_managedId}
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{translate("TURN Key API Token")}</span>
|
||||
<input type="password" name="p2p-turn-apiToken" bind:value={settings.P2P_managedToken}
|
||||
autocomplete="new-password" autocapitalize="off" spellcheck="false" />
|
||||
</label>
|
||||
<p>{translate("The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.")}</p>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p role="status" class="turn-error">{translateIfAvailable(error)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
label { display: grid; gap: 0.25rem; margin: 0.75rem 0; }
|
||||
input, textarea, select { box-sizing: border-box; width: 100%; }
|
||||
p { font-size: var(--font-ui-small, 0.9rem); }
|
||||
.turn-error { color: var(--text-error, #b33); }
|
||||
</style>
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { ObsidianLiveSyncSettings, SettingsMigrationState } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
|
||||
import type {
|
||||
ReviewHarnessScenarioResult,
|
||||
ReviewHarnessScenarioStatus,
|
||||
} from "./reviewHarnessTypes";
|
||||
import type { ReviewHarnessScenarioResult, ReviewHarnessScenarioStatus } from "./reviewHarnessTypes";
|
||||
|
||||
export type { ReviewHarnessScenarioResult, ReviewHarnessScenarioStatus } from "./reviewHarnessTypes";
|
||||
|
||||
@@ -32,6 +29,14 @@ export const REVIEW_HARNESS_SCENARIOS = [
|
||||
mode: "automatic",
|
||||
access: "dedicated-vault-fixtures",
|
||||
},
|
||||
{
|
||||
id: "id-generation-performance",
|
||||
title: "ID generation performance",
|
||||
description:
|
||||
"Measures legacy and independent IDs with fixed in-memory inputs. Reports time per 1,000 IDs and per ID, key derivation time, and JavaScript heap samples where available. Keep Obsidian in the foreground.",
|
||||
mode: "automatic",
|
||||
access: "read-only",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const REVIEW_HARNESS_SCENARIO_IDS = REVIEW_HARNESS_SCENARIOS.map(({ id }) => id);
|
||||
@@ -114,7 +119,9 @@ const NEW_VAULT_RECOMMENDATION_KEYS = [
|
||||
"E2EEAlgorithm",
|
||||
] as const;
|
||||
|
||||
type LifecycleSettingKey = (typeof PRESERVED_SYNC_SETTING_KEYS)[number] | (typeof NEW_VAULT_RECOMMENDATION_KEYS)[number];
|
||||
type LifecycleSettingKey =
|
||||
| (typeof PRESERVED_SYNC_SETTING_KEYS)[number]
|
||||
| (typeof NEW_VAULT_RECOMMENDATION_KEYS)[number];
|
||||
type SettingsForLifecycleInspection = Partial<Pick<ObsidianLiveSyncSettings, LifecycleSettingKey>>;
|
||||
|
||||
export function inspectSettingsLifecycle(input: {
|
||||
@@ -130,9 +137,7 @@ export function inspectSettingsLifecycle(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const invalidSyncSettings = PRESERVED_SYNC_SETTING_KEYS.filter(
|
||||
(key) => typeof input.settings[key] !== "boolean"
|
||||
);
|
||||
const invalidSyncSettings = PRESERVED_SYNC_SETTING_KEYS.filter((key) => typeof input.settings[key] !== "boolean");
|
||||
if (invalidSyncSettings.length > 0) {
|
||||
return {
|
||||
status: "failed",
|
||||
@@ -205,6 +210,7 @@ export interface ReviewHarnessReportScenario {
|
||||
readonly mode: ReviewHarnessScenarioMode;
|
||||
readonly status: ReviewHarnessScenarioStatus;
|
||||
readonly detail: string;
|
||||
readonly observations?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ReviewHarnessReportInput {
|
||||
@@ -248,13 +254,15 @@ export function formatReviewHarnessReport(input: ReviewHarnessReportInput): stri
|
||||
);
|
||||
const scenarios = table(
|
||||
["Scenario", "Mode", "Status", "Detail"],
|
||||
input.scenarios.map(({ id, title, mode, status, detail }) => [
|
||||
`${title} (${id})`,
|
||||
mode,
|
||||
status,
|
||||
detail,
|
||||
])
|
||||
input.scenarios.map(({ id, title, mode, status, detail }) => [`${title} (${id})`, mode, status, detail])
|
||||
);
|
||||
const observations = input.scenarios
|
||||
.filter((scenario) => scenario.observations?.length)
|
||||
.map(
|
||||
({ title, observations }) =>
|
||||
`### ${title}\n\n${observations!.map((value) => `- ${tableCell(value)}`).join("\n")}`
|
||||
)
|
||||
.join("\n\n");
|
||||
return `## Self-hosted LiveSync Review Harness report
|
||||
|
||||
Generated at \`${tableCell(input.generatedAt)}\`.
|
||||
@@ -267,6 +275,8 @@ ${environment}
|
||||
|
||||
${scenarios}
|
||||
|
||||
${observations}
|
||||
|
||||
<details>
|
||||
<summary>Event transcript</summary>
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ describe("Review Harness contract", () => {
|
||||
"settings-lifecycle",
|
||||
"compatibility-review",
|
||||
"vault-round-trip",
|
||||
"id-generation-performance",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface ReviewHarnessRuntime {
|
||||
getCompatibilityPause(): CompatibilityPause | undefined;
|
||||
openCompatibilityReview(): Promise<void>;
|
||||
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
|
||||
runIdBenchmark(): Promise<ReviewHarnessScenarioResult>;
|
||||
readContinuation(): string | null;
|
||||
writeContinuation(value: string): void;
|
||||
deleteContinuation(): void;
|
||||
@@ -159,6 +160,8 @@ export class ReviewHarnessController {
|
||||
});
|
||||
} else if (id === "vault-round-trip") {
|
||||
result = await this.runtime.runVaultRoundTrip();
|
||||
} else if (id === "id-generation-performance") {
|
||||
result = await this.runtime.runIdBenchmark();
|
||||
} else {
|
||||
const inspection = this.inspectCompatibilityReview();
|
||||
result =
|
||||
@@ -206,10 +209,7 @@ export class ReviewHarnessController {
|
||||
detail: "The device-local compatibility review remains pending.",
|
||||
observations: inspection.observations,
|
||||
};
|
||||
this.record(
|
||||
"compatibility-review-updated",
|
||||
this.results["compatibility-review"].status
|
||||
);
|
||||
this.record("compatibility-review-updated", this.results["compatibility-review"].status);
|
||||
} catch (error) {
|
||||
this.setUnexpectedFailure("compatibility-review", error);
|
||||
} finally {
|
||||
@@ -259,6 +259,7 @@ export class ReviewHarnessController {
|
||||
mode,
|
||||
status: this.results[id].status,
|
||||
detail: this.results[id].detail,
|
||||
observations: this.results[id].observations,
|
||||
})),
|
||||
transcript: this.transcript,
|
||||
});
|
||||
|
||||
@@ -80,6 +80,11 @@ function createRuntime(): ReviewHarnessRuntime & {
|
||||
detail: "The owned fixture tree was exercised and removed.",
|
||||
observations: [],
|
||||
})),
|
||||
runIdBenchmark: vi.fn(async () => ({
|
||||
status: "passed" as const,
|
||||
detail: "ID generation measurements completed.",
|
||||
observations: ["Chunk 256 B: 1000 IDs total=43.00 ms; per ID=0.0430 ms"],
|
||||
})),
|
||||
readContinuation() {
|
||||
return this.continuation;
|
||||
},
|
||||
@@ -150,6 +155,60 @@ describe("ReviewHarnessController", () => {
|
||||
expect(runtime.reportError).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("runs ID measurements on request and includes their units in the copied report", async () => {
|
||||
const runtime = createRuntime();
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
await controller.runAutomaticScenarios();
|
||||
expect(runtime.runIdBenchmark).not.toHaveBeenCalled();
|
||||
|
||||
await controller.runScenario("id-generation-performance");
|
||||
await controller.copyReport();
|
||||
|
||||
expect(runtime.runIdBenchmark).toHaveBeenCalledOnce();
|
||||
expect(controller.snapshot().results["id-generation-performance"].status).toBe("passed");
|
||||
expect(vi.mocked(runtime.copyText).mock.calls[0][0]).toContain("1000 IDs total=43.00 ms; per ID=0.0430 ms");
|
||||
expect(runtime.runVaultRoundTrip).not.toHaveBeenCalled();
|
||||
expect(runtime.events).toEqual([]);
|
||||
expect(runtime.continuation).toBeNull();
|
||||
});
|
||||
|
||||
it("excludes an unexpected measurement error from the copied report", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.runIdBenchmark = vi.fn().mockRejectedValue(new Error("private measurement error"));
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
await controller.runScenario("id-generation-performance");
|
||||
|
||||
expect(controller.snapshot().results["id-generation-performance"].status).toBe("failed");
|
||||
expect(controller.createReport()).not.toContain("private measurement error");
|
||||
expect(runtime.reportError).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not overlap an ID measurement with another scenario", async () => {
|
||||
const runtime = createRuntime();
|
||||
let finish!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
runtime.runIdBenchmark = vi.fn(async () => {
|
||||
await pending;
|
||||
return { status: "passed" as const, detail: "Measured", observations: [] };
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
const running = controller.runScenario("id-generation-performance");
|
||||
await controller.runScenario("id-generation-performance");
|
||||
await controller.runScenario("vault-round-trip");
|
||||
|
||||
expect(runtime.runIdBenchmark).toHaveBeenCalledOnce();
|
||||
expect(runtime.runVaultRoundTrip).not.toHaveBeenCalled();
|
||||
expect(controller.snapshot().running).toBe(true);
|
||||
finish();
|
||||
await running;
|
||||
expect(controller.snapshot().running).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes a one-shot continuation before exposing the resumed guided step", () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
@@ -167,9 +226,7 @@ describe("ReviewHarnessController", () => {
|
||||
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
|
||||
status: "waiting-for-user",
|
||||
});
|
||||
expect(controller.snapshot().resumedRequestId).toBe(
|
||||
"compatibility-review-2026-07-18T11:59:00.000Z"
|
||||
);
|
||||
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
||||
});
|
||||
|
||||
it("does not copy rejected continuation values into the report", () => {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
|
||||
|
||||
export interface IdBenchmarkOperations {
|
||||
deriveKey(): Promise<unknown>;
|
||||
chunkId(piece: string, independent: boolean): Promise<string>;
|
||||
documentId(path: string, independent: boolean): Promise<string>;
|
||||
}
|
||||
|
||||
type BenchmarkPerformance = Pick<Performance, "now"> & {
|
||||
readonly memory?: { readonly usedJSHeapSize: number };
|
||||
};
|
||||
|
||||
const ID_COUNT = 1000;
|
||||
const SAMPLES = 3;
|
||||
const BATCH_SIZE = 100;
|
||||
const WARMUP_COUNT = 32;
|
||||
|
||||
function readHeap(clock: BenchmarkPerformance): number | undefined {
|
||||
try {
|
||||
const bytes = clock.memory?.usedJSHeapSize;
|
||||
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function summary(samples: readonly number[]): string {
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
return `median=${sorted[1].toFixed(2)} ms; range=${sorted[0].toFixed(2)}–${sorted[2].toFixed(2)} ms`;
|
||||
}
|
||||
|
||||
export async function runReviewHarnessIdBenchmark(
|
||||
operations: IdBenchmarkOperations,
|
||||
clock: BenchmarkPerformance = performance,
|
||||
yieldControl: () => Promise<void> = () => new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
): Promise<ReviewHarnessScenarioResult> {
|
||||
const before = readHeap(clock);
|
||||
let highest = before;
|
||||
const sampleHeap = () => {
|
||||
const value = readHeap(clock);
|
||||
if (value !== undefined) highest = Math.max(highest ?? value, value);
|
||||
return value;
|
||||
};
|
||||
const observations = [
|
||||
"Fixed synthetic inputs; 3 samples, alternating legacy/independent order; 32 warm-up IDs per sample. Legacy Chunk algorithm: xxhash64.",
|
||||
"Compute timings include input construction and awaited ID generation. Initialisation, warm-up, and pauses between batches are excluded. This does not measure a Rebuild or remote transfer.",
|
||||
];
|
||||
const derivationSamples: number[] = [];
|
||||
for (let sample = 0; sample < SAMPLES; sample++) {
|
||||
await yieldControl();
|
||||
const started = clock.now();
|
||||
await operations.deriveKey();
|
||||
derivationSamples.push(clock.now() - started);
|
||||
sampleHeap();
|
||||
}
|
||||
observations.push(`ID key derivation at save time: ${summary(derivationSamples)} per derivation.`);
|
||||
|
||||
const cases = [
|
||||
...[256, 4096, 32768].map((bytes) => {
|
||||
const prefix = "r".repeat(bytes - 8);
|
||||
return {
|
||||
label: `Chunk IDs, ${bytes} B`,
|
||||
run: (i: number, independent: boolean) =>
|
||||
operations.chunkId(prefix + i.toString(36).padStart(8, "0"), independent),
|
||||
};
|
||||
}),
|
||||
{
|
||||
label: "Obfuscated document IDs",
|
||||
run: (i: number, independent: boolean) => operations.documentId(`benchmark/path-${i}.md`, independent),
|
||||
},
|
||||
];
|
||||
for (const scenario of cases) {
|
||||
const samples: [number[], number[]] = [[], []];
|
||||
for (let sample = 0; sample < SAMPLES; sample++) {
|
||||
for (const independent of sample % 2 === 0 ? [false, true] : [true, false]) {
|
||||
for (let i = 0; i < WARMUP_COUNT; i++) await scenario.run(i, independent);
|
||||
let elapsed = 0;
|
||||
for (let batch = 0; batch < ID_COUNT; batch += BATCH_SIZE) {
|
||||
await yieldControl();
|
||||
const started = clock.now();
|
||||
for (let i = batch; i < batch + BATCH_SIZE; i++) await scenario.run(i, independent);
|
||||
elapsed += clock.now() - started;
|
||||
sampleHeap();
|
||||
}
|
||||
samples[independent ? 1 : 0].push(elapsed);
|
||||
}
|
||||
}
|
||||
for (const [index, values] of samples.entries()) {
|
||||
const median = [...values].sort((a, b) => a - b)[1];
|
||||
observations.push(
|
||||
`${scenario.label}, ${index === 0 ? "legacy" : "independent"}: ${ID_COUNT} IDs total ${summary(values)}; per ID=${(median / ID_COUNT).toFixed(4)} ms.`
|
||||
);
|
||||
}
|
||||
}
|
||||
const after = sampleHeap();
|
||||
if (highest === undefined) {
|
||||
observations.push("JavaScript heap: unavailable on this device.");
|
||||
} else {
|
||||
const mib = (bytes: number | undefined) =>
|
||||
bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`;
|
||||
observations.push(
|
||||
`JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.`
|
||||
);
|
||||
}
|
||||
observations.push(
|
||||
"Heap samples are approximate, may include other Obsidian work, and are affected by garbage collection. They are neither total app RAM nor a true peak."
|
||||
);
|
||||
return { status: "passed", detail: "ID generation measurements completed.", observations };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runReviewHarnessIdBenchmark, type IdBenchmarkOperations } from "./reviewHarnessIdBenchmark";
|
||||
|
||||
function fixture() {
|
||||
let elapsed = 0;
|
||||
let derivations = 0;
|
||||
const chunkCounts = [0, 0];
|
||||
const documentCounts = [0, 0];
|
||||
const chunkSizes = new Set<number>();
|
||||
const operations: IdBenchmarkOperations = {
|
||||
deriveKey: () => {
|
||||
derivations++;
|
||||
elapsed += 42;
|
||||
return Promise.resolve("private-derived-key");
|
||||
},
|
||||
chunkId: (piece, independent) => {
|
||||
chunkCounts[independent ? 1 : 0]++;
|
||||
chunkSizes.add(piece.length);
|
||||
elapsed += independent ? 2 : 1;
|
||||
return Promise.resolve("private-chunk-id");
|
||||
},
|
||||
documentId: (_path, independent) => {
|
||||
documentCounts[independent ? 1 : 0]++;
|
||||
elapsed += independent ? 4 : 3;
|
||||
return Promise.resolve("private-document-id");
|
||||
},
|
||||
};
|
||||
return {
|
||||
operations,
|
||||
now: () => elapsed,
|
||||
yieldControl: () => {
|
||||
elapsed += 100;
|
||||
return Promise.resolve();
|
||||
},
|
||||
counts: () => ({ derivations, chunkCounts, documentCounts, chunkSizes: [...chunkSizes] }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Review Harness ID measurements", () => {
|
||||
it("reports totals and per-ID timings separately, excluding warm-up and cooperative pauses", async () => {
|
||||
const f = fixture();
|
||||
const result = await runReviewHarnessIdBenchmark(f.operations, { now: f.now }, f.yieldControl);
|
||||
const report = result.observations.join("\n");
|
||||
|
||||
expect(result.status).toBe("passed");
|
||||
expect(report).toContain("1000 IDs total median=1000.00 ms; range=1000.00–1000.00 ms; per ID=1.0000 ms");
|
||||
expect(report).toContain("1000 IDs total median=2000.00 ms; range=2000.00–2000.00 ms; per ID=2.0000 ms");
|
||||
expect(report).toContain("Obfuscated document IDs, legacy: 1000 IDs total median=3000.00 ms");
|
||||
expect(report).toContain("Obfuscated document IDs, independent: 1000 IDs total median=4000.00 ms");
|
||||
expect(report).toContain("ID key derivation at save time: median=42.00 ms");
|
||||
expect(report).toContain("JavaScript heap: unavailable on this device.");
|
||||
expect(report).not.toContain("private-");
|
||||
expect(f.counts()).toEqual({
|
||||
derivations: 3,
|
||||
chunkCounts: [9288, 9288],
|
||||
documentCounts: [3096, 3096],
|
||||
chunkSizes: [256, 4096, 32768],
|
||||
});
|
||||
});
|
||||
|
||||
it("labels the highest sampled heap separately from total app RAM and allows a lower final sample", async () => {
|
||||
const f = fixture();
|
||||
let reads = 0;
|
||||
const clock = {
|
||||
now: f.now,
|
||||
get memory() {
|
||||
return { usedJSHeapSize: (reads++ === 0 ? 2 : reads === 2 ? 5 : 1) * 1048576 };
|
||||
},
|
||||
};
|
||||
const result = await runReviewHarnessIdBenchmark(f.operations, clock, f.yieldControl);
|
||||
|
||||
expect(result.observations).toContain(
|
||||
"JavaScript heap: before=2.00 MiB; highest sampled=5.00 MiB; after=1.00 MiB."
|
||||
);
|
||||
expect(result.observations.join("\n")).toContain("neither total app RAM nor a true peak");
|
||||
});
|
||||
|
||||
it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, "throws"])(
|
||||
"keeps timings usable when the heap API returns %s",
|
||||
async (value) => {
|
||||
const f = fixture();
|
||||
const result = await runReviewHarnessIdBenchmark(
|
||||
f.operations,
|
||||
{
|
||||
now: f.now,
|
||||
get memory() {
|
||||
if (value === "throws") throw new Error("Heap API unavailable");
|
||||
return { usedJSHeapSize: value as number };
|
||||
},
|
||||
},
|
||||
f.yieldControl
|
||||
);
|
||||
|
||||
expect(result.status).toBe("passed");
|
||||
expect(result.observations).toContain("JavaScript heap: unavailable on this device.");
|
||||
expect(result.observations.join("\n")).not.toMatch(/NaN|Infinity|private-/u);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { DEFAULT_SETTINGS, deriveIdKey } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { path2id_base } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { HashManager } from "@vrtmrz/livesync-commonlib/hashing";
|
||||
import type { IdBenchmarkOperations } from "./reviewHarnessIdBenchmark";
|
||||
|
||||
const FIXTURE_PASSPHRASE = "Self-hosted LiveSync ID benchmark passphrase";
|
||||
const FIXTURE_SOURCE = "Self-hosted LiveSync ID benchmark source";
|
||||
const FIXTURE_KEY = "ab".repeat(32);
|
||||
|
||||
export async function createIdBenchmarkOperations(): Promise<IdBenchmarkOperations> {
|
||||
const managers: HashManager[] = [];
|
||||
for (const independent of [false, true]) {
|
||||
const settings = Object.freeze({
|
||||
...DEFAULT_SETTINGS,
|
||||
encrypt: true,
|
||||
passphrase: FIXTURE_PASSPHRASE,
|
||||
hashAlg: "xxhash64" as const,
|
||||
idDerivationVersion: independent ? (1 as const) : (0 as const),
|
||||
idDerivationKey: independent ? FIXTURE_KEY : "",
|
||||
});
|
||||
// HashManager only reads currentSettings; this fixture has no storage or live service access.
|
||||
const settingService = { currentSettings: () => settings } as HashManager["options"]["settingService"];
|
||||
const manager = new HashManager({ settingService });
|
||||
if (!(await manager.initialise())) throw new Error("The benchmark hash manager could not initialise.");
|
||||
managers.push(manager);
|
||||
}
|
||||
return {
|
||||
deriveKey: () => deriveIdKey(FIXTURE_SOURCE),
|
||||
chunkId: (piece, independent) => managers[independent ? 1 : 0].computeHash(piece),
|
||||
// Fixture paths are already normalised; use the same ID calculation as PathService.
|
||||
documentId: (path, independent) =>
|
||||
path2id_base(path as FilePath, FIXTURE_PASSPHRASE, false, independent ? FIXTURE_KEY : undefined),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { createIdBenchmarkOperations } from "./reviewHarnessIdBenchmarkRuntime";
|
||||
|
||||
describe("Review Harness benchmark implementation", () => {
|
||||
it("uses the packaged legacy and independent algorithms with isolated fixed settings", async () => {
|
||||
const originalDefaults = structuredClone(DEFAULT_SETTINGS);
|
||||
const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network access is forbidden"));
|
||||
try {
|
||||
const operations = await createIdBenchmarkOperations();
|
||||
const chunk = "r".repeat(256);
|
||||
const legacy = await operations.chunkId(chunk, false);
|
||||
const independent = await operations.chunkId(chunk, true);
|
||||
|
||||
expect(legacy).toMatch(/^\+[0-9a-z]{1,13}$/u);
|
||||
expect(independent).toMatch(/^\+[0-9a-f]{64}$/u);
|
||||
expect(independent).toBe("+9223e53d99e80c29effee9e95e38ed168d13c14f717054f9e996a1cd0a597000");
|
||||
expect(await operations.chunkId(chunk, false)).toBe(legacy);
|
||||
expect(await operations.chunkId(chunk, true)).toBe(independent);
|
||||
expect(await operations.chunkId("s".repeat(256), true)).not.toBe(independent);
|
||||
|
||||
const legacyPath = await operations.documentId("benchmark/path-1.md", false);
|
||||
const independentPath = await operations.documentId("benchmark/path-1.md", true);
|
||||
expect(legacyPath).toMatch(/^f:[0-9a-f]{64}$/u);
|
||||
expect(independentPath).toMatch(/^f:[0-9a-f]{64}$/u);
|
||||
expect(legacyPath).not.toBe(independentPath);
|
||||
expect(await operations.documentId("benchmark/path-1.md", true)).toBe(independentPath);
|
||||
|
||||
const second = await createIdBenchmarkOperations();
|
||||
expect(await second.chunkId(chunk, true)).toBe(independent);
|
||||
expect(await operations.deriveKey()).toMatch(/^[0-9a-f]{64}$/u);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(DEFAULT_SETTINGS).toEqual(originalDefaults);
|
||||
} finally {
|
||||
fetch.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/** The provider identifier persisted in a P2P profile for Cloudflare TURN. */
|
||||
export const CLOUDFLARE_TURN_TYPE = "CF" as const;
|
||||
|
||||
/** The lifetime requested from Cloudflare for each issued credential set. */
|
||||
export const CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS = 86_400 as const;
|
||||
|
||||
/** The Cloudflare TURN credential-generation endpoint. */
|
||||
export const CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT = "https://rtc.live.cloudflare.com/v1/turn/keys" as const;
|
||||
|
||||
/** A Cloudflare TURN configuration. */
|
||||
export interface CloudflareTurnConfiguration {
|
||||
readonly turnKeyId: string;
|
||||
readonly apiToken: string;
|
||||
}
|
||||
|
||||
// TURN Key IDs are inserted into one fixed URL path. Keep the accepted set
|
||||
// deliberately narrower than URI escaping so a configuration cannot alter
|
||||
// the request path or add a query string.
|
||||
const TURN_KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/;
|
||||
|
||||
// RFC 6750's b64token grammar, including optional trailing padding. This
|
||||
// also excludes whitespace and control characters from the Authorization
|
||||
// header without exposing the token in a validation message.
|
||||
const BEARER_TOKEN_PATTERN = /^[A-Za-z0-9._~+/-]+={0,2}$/;
|
||||
const MAX_BEARER_TOKEN_LENGTH = 4_096;
|
||||
|
||||
/**
|
||||
* Returns a safe validation message for a Cloudflare TURN configuration.
|
||||
* The result never includes the supplied Key ID or API token.
|
||||
*/
|
||||
export function validateCloudflareTurnConfiguration(value: CloudflareTurnConfiguration): string | undefined {
|
||||
const turnKeyId = value.turnKeyId;
|
||||
if (typeof turnKeyId !== "string" || turnKeyId.length === 0) {
|
||||
return "Enter a TURN Key ID.";
|
||||
}
|
||||
if (!TURN_KEY_ID_PATTERN.test(turnKeyId)) {
|
||||
return "TURN Key ID contains unsupported characters.";
|
||||
}
|
||||
|
||||
const apiToken = value.apiToken;
|
||||
if (typeof apiToken !== "string" || apiToken.length === 0) {
|
||||
return "Enter a TURN Key API Token.";
|
||||
}
|
||||
if (apiToken.length > MAX_BEARER_TOKEN_LENGTH || !BEARER_TOKEN_PATTERN.test(apiToken)) {
|
||||
return "TURN Key API Token must use Bearer token syntax.";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import {
|
||||
CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT,
|
||||
CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS,
|
||||
type CloudflareTurnConfiguration,
|
||||
validateCloudflareTurnConfiguration,
|
||||
} from "./settings";
|
||||
import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
/** Fetch-compatible function supplied by the host composition. */
|
||||
export type CloudflareTurnFetch = (input: string | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface CloudflareTurnDependencies {
|
||||
readonly fetch: CloudflareTurnFetch;
|
||||
readonly now?: () => number;
|
||||
readonly requestDeadlineMs?: number;
|
||||
}
|
||||
|
||||
export const CLOUDFLARE_TURN_REQUEST_DEADLINE_MS = 15_000 as const;
|
||||
export const CLOUDFLARE_TURN_MAX_RESPONSE_BYTES = 32 * 1024;
|
||||
export const CLOUDFLARE_TURN_MAX_ICE_SERVER_ENTRIES = 16 as const;
|
||||
export const CLOUDFLARE_TURN_MAX_ICE_SERVER_URLS = 32 as const;
|
||||
export const CLOUDFLARE_TURN_MIN_REMAINING_LIFETIME_MS = 30_000 as const;
|
||||
|
||||
type TurnFailureCode = "configuration" | "authentication" | "unavailable" | "invalid-response";
|
||||
|
||||
const FAILURE_MESSAGES: Record<TurnFailureCode, string> = {
|
||||
configuration: "The Cloudflare TURN configuration is invalid.",
|
||||
authentication: "The Cloudflare TURN credential request was not authorised.",
|
||||
unavailable: "The Cloudflare TURN service is unavailable.",
|
||||
"invalid-response": "The Cloudflare TURN service returned an invalid response.",
|
||||
};
|
||||
|
||||
function credentialFailure(code: TurnFailureCode, retryable: boolean): Error {
|
||||
return Object.assign(new Error(FAILURE_MESSAGES[code]), { code, retryable });
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
try {
|
||||
return new DOMException("The operation was aborted.", "AbortError");
|
||||
} catch {
|
||||
const error = new Error("The operation was aborted.");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal): void {
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
}
|
||||
|
||||
function isControlCharacter(value: string): boolean {
|
||||
return Array.from(value).some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code <= 0x1f || code === 0x7f;
|
||||
});
|
||||
}
|
||||
|
||||
function isPort(value: string): boolean {
|
||||
if (!/^\d{1,5}$/.test(value)) return false;
|
||||
const port = Number(value);
|
||||
return port >= 1 && port <= 65_535;
|
||||
}
|
||||
|
||||
function isHost(value: string): boolean {
|
||||
return value.length > 0 && /^[A-Za-z0-9._-]+$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the URL forms accepted by WebRTC's ICE server configuration.
|
||||
* TURN URLs may carry only the standard transport query parameter; userinfo,
|
||||
* paths, fragments, and arbitrary query values are not accepted.
|
||||
*/
|
||||
export function isSupportedIceServerUrl(value: string): boolean {
|
||||
if (value.length === 0 || value.length > 2_048 || isControlCharacter(value)) return false;
|
||||
const schemeMatch = /^(stun|stuns|turn|turns):(.+)$/i.exec(value);
|
||||
if (!schemeMatch) return false;
|
||||
|
||||
const remainder = schemeMatch[2];
|
||||
const queryIndex = remainder.indexOf("?");
|
||||
const authority = queryIndex >= 0 ? remainder.slice(0, queryIndex) : remainder;
|
||||
const query = queryIndex >= 0 ? remainder.slice(queryIndex + 1) : "";
|
||||
if (authority.length === 0 || authority.includes("/") || authority.includes("#") || authority.includes("@")) {
|
||||
return false;
|
||||
}
|
||||
if (authority.includes("%")) return false;
|
||||
|
||||
if (authority.startsWith("[")) {
|
||||
const closingBracket = authority.indexOf("]");
|
||||
if (closingBracket < 0) return false;
|
||||
const host = authority.slice(1, closingBracket);
|
||||
if (!/^[0-9A-Fa-f:.]+$/.test(host) || !host.includes(":")) return false;
|
||||
const suffix = authority.slice(closingBracket + 1);
|
||||
if (suffix !== "" && (!suffix.startsWith(":") || !isPort(suffix.slice(1)))) return false;
|
||||
} else {
|
||||
const colonIndex = authority.lastIndexOf(":");
|
||||
const host = colonIndex >= 0 ? authority.slice(0, colonIndex) : authority;
|
||||
if (!isHost(host) || (colonIndex >= 0 && !isPort(authority.slice(colonIndex + 1)))) return false;
|
||||
// IPv6 literals must use brackets so a colon cannot be interpreted as
|
||||
// an ambiguous port separator.
|
||||
if (colonIndex >= 0 && host.includes(":")) return false;
|
||||
}
|
||||
|
||||
if (query.length === 0) return true;
|
||||
const queryParts = query.split("&");
|
||||
return queryParts.length === 1 && /^transport=(udp|tcp)$/i.test(queryParts[0]);
|
||||
}
|
||||
|
||||
function isTurnUrl(value: string): boolean {
|
||||
return /^(turn|turns):/i.test(value);
|
||||
}
|
||||
|
||||
function isCredential(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 4_096 && !isControlCharacter(value);
|
||||
}
|
||||
|
||||
function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
|
||||
if (!isRecord(value) || !Array.isArray(value.iceServers)) {
|
||||
throw credentialFailure("invalid-response", false);
|
||||
}
|
||||
if (value.iceServers.length === 0 || value.iceServers.length > CLOUDFLARE_TURN_MAX_ICE_SERVER_ENTRIES) {
|
||||
throw credentialFailure("invalid-response", false);
|
||||
}
|
||||
|
||||
const servers: RTCIceServer[] = [];
|
||||
let urlCount = 0;
|
||||
let hasTurnServer = false;
|
||||
|
||||
for (const candidate of value.iceServers) {
|
||||
if (!isRecord(candidate)) throw credentialFailure("invalid-response", false);
|
||||
const rawUrls = candidate.urls;
|
||||
const urls =
|
||||
typeof rawUrls === "string"
|
||||
? [rawUrls]
|
||||
: Array.isArray(rawUrls) && rawUrls.every((url): url is string => typeof url === "string")
|
||||
? [...rawUrls]
|
||||
: undefined;
|
||||
if (!urls || urls.length === 0) throw credentialFailure("invalid-response", false);
|
||||
|
||||
urlCount += urls.length;
|
||||
if (urlCount > CLOUDFLARE_TURN_MAX_ICE_SERVER_URLS || urls.some((url) => !isSupportedIceServerUrl(url))) {
|
||||
throw credentialFailure("invalid-response", false);
|
||||
}
|
||||
|
||||
const turnEntry = urls.some(isTurnUrl);
|
||||
hasTurnServer ||= turnEntry;
|
||||
const normalised: RTCIceServer = { urls };
|
||||
if (turnEntry) {
|
||||
if (!isCredential(candidate.username) || !isCredential(candidate.credential)) {
|
||||
throw credentialFailure("invalid-response", false);
|
||||
}
|
||||
normalised.username = candidate.username;
|
||||
normalised.credential = candidate.credential;
|
||||
}
|
||||
servers.push(normalised);
|
||||
}
|
||||
|
||||
if (!hasTurnServer) throw credentialFailure("invalid-response", false);
|
||||
return Object.freeze(servers);
|
||||
}
|
||||
|
||||
class BoundedResponseError extends Error {
|
||||
constructor(readonly kind: "too-large" | "invalid-length" | "read-failed") {
|
||||
super(kind);
|
||||
}
|
||||
}
|
||||
|
||||
async function readResponseBody(response: Response): Promise<string> {
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength !== null) {
|
||||
const declaredLength = Number(contentLength);
|
||||
if (!Number.isFinite(declaredLength) || declaredLength < 0) {
|
||||
throw new BoundedResponseError("invalid-length");
|
||||
}
|
||||
if (declaredLength > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
|
||||
throw new BoundedResponseError("too-large");
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (new TextEncoder().encode(text).byteLength > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
|
||||
throw new BoundedResponseError("too-large");
|
||||
}
|
||||
return text;
|
||||
} catch (error) {
|
||||
if (error instanceof BoundedResponseError) throw error;
|
||||
throw new BoundedResponseError("read-failed");
|
||||
}
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const result = await reader.read();
|
||||
if (result.done) break;
|
||||
totalBytes += result.value.byteLength;
|
||||
if (totalBytes > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// The response is already invalid because it exceeded the
|
||||
// bound; cancellation failure must not change the safe
|
||||
// classification or expose a host-specific error.
|
||||
}
|
||||
throw new BoundedResponseError("too-large");
|
||||
}
|
||||
chunks.push(result.value);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof BoundedResponseError) throw error;
|
||||
throw new BoundedResponseError("read-failed");
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function classifyHttpFailure(status: number): Error {
|
||||
if (status === 401 || status === 403) {
|
||||
return credentialFailure("authentication", false);
|
||||
}
|
||||
if (status === 408 || status === 429 || status >= 500) {
|
||||
return credentialFailure("unavailable", true);
|
||||
}
|
||||
return credentialFailure("unavailable", false);
|
||||
}
|
||||
|
||||
function parseResponseBody(body: string): readonly RTCIceServer[] {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(body) as unknown;
|
||||
} catch {
|
||||
throw credentialFailure("invalid-response", false);
|
||||
}
|
||||
return normaliseIceServers(value);
|
||||
}
|
||||
|
||||
/** Acquire one temporary ICE configuration for a new room connection. */
|
||||
export async function acquireCloudflareTurnCredentials(
|
||||
configuration: CloudflareTurnConfiguration,
|
||||
dependencies: CloudflareTurnDependencies,
|
||||
signal: AbortSignal
|
||||
): Promise<{ iceServers: readonly RTCIceServer[]; expiresAt: number }> {
|
||||
if (validateCloudflareTurnConfiguration(configuration)) throw credentialFailure("configuration", false);
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const requestDeadlineMs = dependencies.requestDeadlineMs ?? CLOUDFLARE_TURN_REQUEST_DEADLINE_MS;
|
||||
throwIfAborted(signal);
|
||||
const requestStartedAt = now();
|
||||
if (!Number.isFinite(requestStartedAt)) {
|
||||
throw credentialFailure("unavailable", true);
|
||||
}
|
||||
|
||||
const requestController = new AbortController();
|
||||
let cancelledByCaller = false;
|
||||
let rejectCaller: ((reason?: unknown) => void) | undefined;
|
||||
const callerAbort = new Promise<never>((_resolve, reject) => {
|
||||
rejectCaller = reject;
|
||||
});
|
||||
let timedOut = false;
|
||||
const onAbort = () => {
|
||||
cancelledByCaller = true;
|
||||
requestController.abort();
|
||||
rejectCaller?.(abortError());
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
requestController.abort();
|
||||
throw abortError();
|
||||
}
|
||||
let timeoutId: CompatTimeoutHandle | undefined;
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
timeoutId = compatGlobal.setTimeout(() => {
|
||||
timedOut = true;
|
||||
requestController.abort();
|
||||
reject(credentialFailure("unavailable", true));
|
||||
}, requestDeadlineMs);
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId !== undefined) compatGlobal.clearTimeout(timeoutId);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
|
||||
const endpoint = `${CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT}/${configuration.turnKeyId}/credentials/generate-ice-servers`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await Promise.race([
|
||||
dependencies.fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${configuration.apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ttl: CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS }),
|
||||
signal: requestController.signal,
|
||||
redirect: "error",
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
}),
|
||||
callerAbort,
|
||||
deadline,
|
||||
]);
|
||||
} catch {
|
||||
cleanup();
|
||||
if (cancelledByCaller || signal.aborted) throw abortError();
|
||||
if (timedOut) throw credentialFailure("unavailable", true);
|
||||
throw credentialFailure("unavailable", true);
|
||||
}
|
||||
|
||||
if (cancelledByCaller || signal.aborted) {
|
||||
cleanup();
|
||||
throw abortError();
|
||||
}
|
||||
if (timedOut || requestController.signal.aborted) {
|
||||
cleanup();
|
||||
throw credentialFailure("unavailable", true);
|
||||
}
|
||||
if (response.status !== 201) {
|
||||
cleanup();
|
||||
throw classifyHttpFailure(response.status);
|
||||
}
|
||||
|
||||
let body: string;
|
||||
try {
|
||||
body = await Promise.race([readResponseBody(response), callerAbort, deadline]);
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
if (cancelledByCaller || signal.aborted) throw abortError();
|
||||
if (timedOut) throw credentialFailure("unavailable", true);
|
||||
if (error instanceof BoundedResponseError && error.kind === "read-failed") {
|
||||
throw credentialFailure("unavailable", true);
|
||||
}
|
||||
throw credentialFailure("invalid-response", false);
|
||||
}
|
||||
|
||||
try {
|
||||
throwIfAborted(signal);
|
||||
const iceServers = parseResponseBody(body);
|
||||
const expiresAt = requestStartedAt + CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000;
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= now() + CLOUDFLARE_TURN_MIN_REMAINING_LIFETIME_MS) {
|
||||
throw credentialFailure("invalid-response", false);
|
||||
}
|
||||
return { iceServers, expiresAt };
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CLOUDFLARE_TURN_MAX_RESPONSE_BYTES,
|
||||
CLOUDFLARE_TURN_REQUEST_DEADLINE_MS,
|
||||
acquireCloudflareTurnCredentials,
|
||||
} from "./turnCredentials";
|
||||
import {
|
||||
CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT,
|
||||
CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS,
|
||||
validateCloudflareTurnConfiguration,
|
||||
} from "./settings";
|
||||
|
||||
const configuration = {
|
||||
turnKeyId: "key-123",
|
||||
apiToken: "token_abc-123",
|
||||
} as const;
|
||||
|
||||
function response(body: unknown, status = 201): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function validBody() {
|
||||
return {
|
||||
iceServers: [
|
||||
{
|
||||
urls: ["turn:relay.example.test:3478?transport=udp", "turns:relay.example.test:5349"],
|
||||
username: "turn-user",
|
||||
credential: "turn-password",
|
||||
},
|
||||
{ urls: "stun:stun.example.test:3478" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Cloudflare TURN credentials", () => {
|
||||
it("requests the fixed endpoint with the bearer token and TTL", async () => {
|
||||
const now = 1_000_000;
|
||||
let requestUrl: string | Request | undefined;
|
||||
let requestInit: RequestInit | undefined;
|
||||
const fetch = vi.fn(async (input: string | Request, init?: RequestInit) => {
|
||||
requestUrl = input;
|
||||
requestInit = init;
|
||||
return response(validBody());
|
||||
});
|
||||
const dependencies = { fetch, now: () => now };
|
||||
|
||||
const result = await acquireCloudflareTurnCredentials(
|
||||
configuration,
|
||||
dependencies,
|
||||
new AbortController().signal
|
||||
);
|
||||
|
||||
expect(requestUrl).toBe(`${CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT}/key-123/credentials/generate-ice-servers`);
|
||||
expect(requestInit).toMatchObject({
|
||||
method: "POST",
|
||||
redirect: "error",
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({ ttl: CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS }),
|
||||
});
|
||||
expect(new Headers(requestInit?.headers).get("authorization")).toBe("Bearer token_abc-123");
|
||||
expect(new Headers(requestInit?.headers).get("content-type")).toBe("application/json");
|
||||
expect(requestInit?.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(result.iceServers).toHaveLength(2);
|
||||
expect(result.expiresAt).toBe(now + CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000);
|
||||
});
|
||||
|
||||
it("rejects malformed, oversized, and STUN-only responses without exposing secrets", async () => {
|
||||
const cases: Array<{ body: unknown; expectedCode: string }> = [
|
||||
{ body: { iceServers: [] }, expectedCode: "invalid-response" },
|
||||
{ body: { iceServers: [{ urls: "turn:relay.example.test:3478" }] }, expectedCode: "invalid-response" },
|
||||
{ body: { iceServers: [{ urls: "stun:stun.example.test:3478" }] }, expectedCode: "invalid-response" },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const dependencies = {
|
||||
fetch: vi.fn(async () => response(testCase.body)),
|
||||
now: () => 1_000_000,
|
||||
};
|
||||
const error = await acquireCloudflareTurnCredentials(
|
||||
configuration,
|
||||
dependencies,
|
||||
new AbortController().signal
|
||||
).catch((reason: unknown) => reason);
|
||||
expect(error).toMatchObject({ code: testCase.expectedCode });
|
||||
expect(String(error)).not.toContain(configuration.apiToken);
|
||||
expect(String(error)).not.toContain(configuration.turnKeyId);
|
||||
}
|
||||
|
||||
const oversized = "x".repeat(CLOUDFLARE_TURN_MAX_RESPONSE_BYTES + 1);
|
||||
const dependencies = {
|
||||
fetch: vi.fn(async () => new Response(oversized, { status: 201 })),
|
||||
now: () => 1_000_000,
|
||||
};
|
||||
const error = await acquireCloudflareTurnCredentials(
|
||||
configuration,
|
||||
dependencies,
|
||||
new AbortController().signal
|
||||
).catch((reason: unknown) => reason);
|
||||
expect(error).toMatchObject({ code: "invalid-response" });
|
||||
});
|
||||
|
||||
it("classifies authentication and transient provider failures", async () => {
|
||||
const authDependencies = {
|
||||
fetch: vi.fn(async () => response({}, 401)),
|
||||
};
|
||||
await expect(
|
||||
acquireCloudflareTurnCredentials(configuration, authDependencies, new AbortController().signal)
|
||||
).rejects.toMatchObject({
|
||||
code: "authentication",
|
||||
retryable: false,
|
||||
});
|
||||
|
||||
const transientDependencies = {
|
||||
fetch: vi.fn(async () => response({}, 503)),
|
||||
};
|
||||
await expect(
|
||||
acquireCloudflareTurnCredentials(configuration, transientDependencies, new AbortController().signal)
|
||||
).rejects.toMatchObject({
|
||||
code: "unavailable",
|
||||
retryable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates caller cancellation and turns a deadline into an unavailable failure", async () => {
|
||||
const controller = new AbortController();
|
||||
const fetch = vi.fn((_input: string | Request, init?: RequestInit) => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
const dependencies = { fetch };
|
||||
const cancelled = acquireCloudflareTurnCredentials(configuration, dependencies, controller.signal);
|
||||
controller.abort();
|
||||
await expect(cancelled).rejects.toMatchObject({ name: "AbortError" });
|
||||
|
||||
vi.useFakeTimers();
|
||||
const timedDependencies = { fetch };
|
||||
const timed = acquireCloudflareTurnCredentials(configuration, timedDependencies, new AbortController().signal);
|
||||
const assertion = expect(timed).rejects.toMatchObject({ code: "unavailable", retryable: true });
|
||||
await vi.advanceTimersByTimeAsync(CLOUDFLARE_TURN_REQUEST_DEADLINE_MS);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it("rejects an issuance which has no usable remaining lifetime", async () => {
|
||||
let now = 1_000_000;
|
||||
const dependencies = {
|
||||
fetch: vi.fn(async () => {
|
||||
now += CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000;
|
||||
return response(validBody());
|
||||
}),
|
||||
now: () => now,
|
||||
};
|
||||
await expect(
|
||||
acquireCloudflareTurnCredentials(configuration, dependencies, new AbortController().signal)
|
||||
).rejects.toMatchObject({
|
||||
code: "invalid-response",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cloudflare TURN input validation", () => {
|
||||
it("rejects unsafe key IDs and malformed bearer credentials", () => {
|
||||
expect(
|
||||
validateCloudflareTurnConfiguration({ turnKeyId: "key/id", apiToken: configuration.apiToken })
|
||||
).toContain("unsupported characters");
|
||||
expect(validateCloudflareTurnConfiguration({ ...configuration, apiToken: "token with spaces" })).toContain(
|
||||
"Bearer token syntax"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { P2PConnectionInfo } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { CLOUDFLARE_TURN_TYPE, validateCloudflareTurnConfiguration } from "./cloudflare/settings";
|
||||
|
||||
/** Validate provider inputs without requesting credentials. */
|
||||
export function validateManagedTurnSettings(settings: Partial<P2PConnectionInfo>): string | undefined {
|
||||
if (settings.P2P_managedType === undefined || settings.P2P_managedType === "") return undefined;
|
||||
if (settings.P2P_managedType !== CLOUDFLARE_TURN_TYPE) {
|
||||
return "The selected TURN configuration is not supported.";
|
||||
}
|
||||
return validateCloudflareTurnConfiguration({
|
||||
turnKeyId: settings.P2P_managedId ?? "",
|
||||
apiToken: settings.P2P_managedToken ?? "",
|
||||
});
|
||||
}
|
||||
+3
-1
@@ -1,3 +1,4 @@
|
||||
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
|
||||
import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps";
|
||||
import { setGetLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
setGetLanguage(getLanguage);
|
||||
@@ -182,7 +183,8 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
const replicator = useP2PReplicatorFeature(
|
||||
core,
|
||||
(_compatibilityReplicator, p2p) => createInteractiveP2PReplication(p2p),
|
||||
createOpenRebuildUI(this.app)
|
||||
createOpenRebuildUI(this.app),
|
||||
{ prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)) }
|
||||
);
|
||||
setupManager.registerP2PSetupConnectionProbe(replicator.connectionProbe);
|
||||
useP2PReplicatorCommands(core, replicator);
|
||||
|
||||
@@ -99,6 +99,17 @@ function resolutionSettingsSignature(settings: ObsidianLiveSyncSettings): string
|
||||
}
|
||||
|
||||
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
private requiresIdConfigurationReview(assessment: TweakAssessment): boolean {
|
||||
if (!assessment.entries.some(({ key, relation }) => key === "idDerivationVersion" && relation === "different")) {
|
||||
return false;
|
||||
}
|
||||
Logger(
|
||||
"The document ID configurations differ. Import the correct Setup URI, or configure the matching ID key, before synchronising.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private _selectNewerTweakSide(current: TweakValues, preferred: Partial<TweakValues>): "REMOTE" | "CURRENT" {
|
||||
Logger(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`);
|
||||
const currentModified = current.tweakModified;
|
||||
@@ -196,6 +207,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
assessment = assessTweakCompatibility(this.settings, preferred)
|
||||
): Promise<[TweakValues | boolean, boolean]> {
|
||||
if (assessment.alignment === "matched") return [false, false];
|
||||
if (this.requiresIdConfigurationReview(assessment)) return [false, false];
|
||||
const acceptedSettings = settingsAfterAdoption(assessment, "adoptPreferred");
|
||||
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(assessment);
|
||||
if (autoAcceptSide === "REMOTE") return [acceptedSettings, false];
|
||||
@@ -363,6 +375,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
const trialSignature = JSON.stringify(trialSetting);
|
||||
const currentSignature = resolutionSettingsSignature(this.settings);
|
||||
const assessment = assessTweakCompatibility(trialSetting, preferred);
|
||||
if (this.requiresIdConfigurationReview(assessment)) return { result: false, requireFetch: false };
|
||||
if (assessment.alignment === "matched") {
|
||||
this._log("The settings in the remote database are the same as the local database.", LOG_LEVEL_NOTICE);
|
||||
return { result: false, requireFetch: false };
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type TweakValues,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { assessTweakCompatibility, configuredIdKey } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
|
||||
import { setLang } from "@/common/translation";
|
||||
import {
|
||||
@@ -74,6 +74,68 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
}
|
||||
|
||||
describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
it.each([0, 1] as const)(
|
||||
"keeps ID configuration %s when automatically aligning Chunk settings",
|
||||
async (idDerivationVersion) => {
|
||||
const idDerivationKey = idDerivationVersion === 1 ? "ab".repeat(32) : "";
|
||||
const { module, core, askSelectStringDialogue } = createModule({
|
||||
encrypt: true,
|
||||
usePathObfuscation: false,
|
||||
idDerivationVersion,
|
||||
idDerivationKey,
|
||||
autoAcceptCompatibleTweak: true,
|
||||
hashAlg: "xxhash64",
|
||||
tweakModified: 1,
|
||||
});
|
||||
const preferred: TweakValues = {
|
||||
...extractObject(TweakValuesTemplate, core.settings),
|
||||
idDerivationVersion: idDerivationVersion === 1 ? 0 : 1,
|
||||
hashAlg: "xxhash32",
|
||||
tweakModified: 2,
|
||||
};
|
||||
core._services.tweakValue = {
|
||||
checkAndAskResolvingMismatched: module._checkAndAskResolvingMismatchedTweaks.bind(module),
|
||||
};
|
||||
core._services.setting.saveSettingData.mockImplementation(async () => {
|
||||
configuredIdKey(core.settings);
|
||||
});
|
||||
|
||||
await expect(module._askResolvingMismatchedTweaks(preferred, async () => true)).resolves.toBe("CHECKAGAIN");
|
||||
|
||||
expect(core.settings).toMatchObject({ idDerivationVersion, idDerivationKey, hashAlg: "xxhash32" });
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["active", "trial"] as const)(
|
||||
"withholds ordinary tweak adoption for different document ID modes (%s)",
|
||||
async (route) => {
|
||||
const { module, core, askSelectStringDialogue } = createModule({
|
||||
encrypt: true,
|
||||
usePathObfuscation: true,
|
||||
idDerivationVersion: 0,
|
||||
idDerivationKey: "",
|
||||
});
|
||||
const preferred: TweakValues = {
|
||||
...extractObject(TweakValuesTemplate, core.settings),
|
||||
idDerivationVersion: 1,
|
||||
};
|
||||
|
||||
if (route === "active") {
|
||||
await expect(module._checkAndAskResolvingMismatchedTweaks(preferred)).resolves.toEqual([false, false]);
|
||||
} else {
|
||||
await expect(module._askUseRemoteConfiguration(core.settings, preferred)).resolves.toEqual({
|
||||
result: false,
|
||||
requireFetch: false,
|
||||
});
|
||||
}
|
||||
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(core._services.setting.saveSettingData).not.toHaveBeenCalled();
|
||||
expect(core.settings).toMatchObject({ idDerivationVersion: 0, idDerivationKey: "" });
|
||||
}
|
||||
);
|
||||
|
||||
it("compatibility: offers ordinary application for a missing legacy filename-case setting", async () => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: false,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
import { fireAndForget, isDirty, throttle } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import {
|
||||
collectingChunks,
|
||||
chunkFetchCounts,
|
||||
pluginScanningCount,
|
||||
hiddenFilesEventCount,
|
||||
hiddenFilesProcessingCount,
|
||||
@@ -36,7 +36,11 @@ import {
|
||||
formatRemoteActivityStatusLabel,
|
||||
getTrackedRequestCount,
|
||||
} from "./RemoteActivityStatus.ts";
|
||||
import { createMinimumVisibleActivityCount, createPaddedCounterLabel } from "./StatusBarDisplay.ts";
|
||||
import {
|
||||
createChunkFetchCounterLabel,
|
||||
createMinimumVisibleActivityCount,
|
||||
createPaddedCounterLabel,
|
||||
} from "./StatusBarDisplay.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
import { isValidPath } from "@/common/utils.ts";
|
||||
@@ -140,7 +144,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
const labelStorageCount = registerDisplay(
|
||||
createPaddedCounterLabel(this.services.replication.storageApplyingCount, `💾`)
|
||||
);
|
||||
const labelChunkCount = registerDisplay(createPaddedCounterLabel(collectingChunks, `🧩`));
|
||||
const labelChunkCount = registerDisplay(createChunkFetchCounterLabel(chunkFetchCounts));
|
||||
const labelPluginScanCount = registerDisplay(createPaddedCounterLabel(pluginScanningCount, `🔌`));
|
||||
const labelConflictProcessCount = registerDisplay(
|
||||
createPaddedCounterLabel(this.services.conflict.conflictProcessQueueCount, `🔩`)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
hasManagedTurnSettings,
|
||||
omitManagedTurnProfilesFromMarkdown,
|
||||
preserveManagedTurnProfilesOnMarkdownImport,
|
||||
} from "@/common/turnSettingsPrivacy";
|
||||
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser";
|
||||
import { isObjectDifferent } from "octagonal-wheels/object";
|
||||
import { EVENT_SETTING_SAVED, eventHub } from "@/common/events";
|
||||
@@ -129,11 +134,14 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
|
||||
|
||||
let settingToApply = { ...DEFAULT_SETTINGS } as ObsidianLiveSyncSettings;
|
||||
settingToApply = { ...settingToApply, ...newSetting };
|
||||
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,
|
||||
@@ -197,22 +205,31 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
|
||||
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;
|
||||
}
|
||||
omitManagedTurnProfilesFromMarkdown(saveData);
|
||||
return saveData;
|
||||
}
|
||||
|
||||
async saveSettingToMarkdown(filename: string) {
|
||||
const saveData = this.generateSettingForMarkdown();
|
||||
if (hasManagedTurnSettings(this.settings)) {
|
||||
this._log(
|
||||
"Share TURN provider credentials through an encrypted Setup URI. Connection profiles are omitted from Markdown settings.",
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
const file = await this.core.storageAccess.isExists(filename);
|
||||
|
||||
if (!file) {
|
||||
|
||||
@@ -569,6 +569,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
}
|
||||
|
||||
// Internal Metadata encryption affects future Metadata writes and is not a rebuild requirement.
|
||||
isNeedRebuildLocal() {
|
||||
return this.isSomeDirty([
|
||||
"useIndexedDBAdapter",
|
||||
|
||||
@@ -47,6 +47,12 @@ function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianL
|
||||
}
|
||||
return workObj;
|
||||
}
|
||||
|
||||
function syncIdDerivationSettings(target: Partial<ObsidianLiveSyncSettings>, source: ObsidianLiveSyncSettings): void {
|
||||
target.idDerivationVersion = source.idDerivationVersion;
|
||||
target.idDerivationKey = source.idDerivationKey;
|
||||
}
|
||||
|
||||
function createRemoteConfigurationId(): string {
|
||||
return `remote-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -116,7 +122,28 @@ export function paneRemoteConfig(
|
||||
.onClick(async () => {
|
||||
const setupManager = this.core.getModule(SetupManager);
|
||||
const originalSettings = getSettingsFromEditingSettings(this.editingSettings);
|
||||
await setupManager.onlyE2EEConfiguration(UserMode.Update, originalSettings);
|
||||
const originalIdDerivationVersion = this.core.settings.idDerivationVersion;
|
||||
const originalIdDerivationKey = this.core.settings.idDerivationKey;
|
||||
const applied = await setupManager.onlyE2EEConfiguration(UserMode.Update, originalSettings);
|
||||
if (applied) {
|
||||
this.editingSettings.encryptInternalMetadata =
|
||||
this.core.settings.encryptInternalMetadata;
|
||||
if (this.initialSettings) {
|
||||
this.initialSettings.encryptInternalMetadata =
|
||||
this.core.settings.encryptInternalMetadata;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
if (
|
||||
this.core.settings.idDerivationVersion !== originalIdDerivationVersion ||
|
||||
this.core.settings.idDerivationKey !== originalIdDerivationKey
|
||||
) {
|
||||
syncIdDerivationSettings(this.editingSettings, this.core.settings);
|
||||
if (this.initialSettings) {
|
||||
syncIdDerivationSettings(this.initialSettings, this.core.settings);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
updateE2EESummary();
|
||||
})
|
||||
.setButtonText("Configure")
|
||||
@@ -155,9 +182,11 @@ export function paneRemoteConfig(
|
||||
const currentConfigs = cloneRemoteConfigurations(this.core.settings.remoteConfigurations);
|
||||
this.editingSettings.remoteConfigurations = currentConfigs;
|
||||
this.editingSettings.activeConfigurationId = this.core.settings.activeConfigurationId;
|
||||
syncIdDerivationSettings(this.editingSettings, this.core.settings);
|
||||
if (this.initialSettings) {
|
||||
this.initialSettings.remoteConfigurations = cloneRemoteConfigurations(currentConfigs);
|
||||
this.initialSettings.activeConfigurationId = this.core.settings.activeConfigurationId;
|
||||
syncIdDerivationSettings(this.initialSettings, this.core.settings);
|
||||
}
|
||||
};
|
||||
const persistRemoteConfigurations = async (synchroniseActiveRemote: boolean = false) => {
|
||||
@@ -243,7 +272,10 @@ export function paneRemoteConfig(
|
||||
...DEFAULT_SETTINGS,
|
||||
encrypt: this.editingSettings.encrypt,
|
||||
usePathObfuscation: this.editingSettings.usePathObfuscation,
|
||||
encryptInternalMetadata: this.editingSettings.encryptInternalMetadata,
|
||||
passphrase: this.editingSettings.passphrase,
|
||||
idDerivationVersion: this.editingSettings.idDerivationVersion,
|
||||
idDerivationKey: this.editingSettings.idDerivationKey,
|
||||
configPassphraseStore: this.editingSettings.configPassphraseStore,
|
||||
});
|
||||
const addRemoteConfiguration = async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
buttonClasses: [] as string[],
|
||||
clickHandlers: [] as Array<() => Promise<void> | void>,
|
||||
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
|
||||
settingClasses: [] as string[],
|
||||
}));
|
||||
@@ -51,7 +52,8 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
setDestructive() {
|
||||
return this;
|
||||
},
|
||||
onClick() {
|
||||
onClick(callback: () => Promise<void> | void) {
|
||||
runtime.clickHandlers.push(callback);
|
||||
return this;
|
||||
},
|
||||
setButtonText() {
|
||||
@@ -97,6 +99,7 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/ConnectionString", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemote.svelte", () => ({ default: {} }));
|
||||
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte", () => ({ default: {} }));
|
||||
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte", () => ({ default: {} }));
|
||||
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} }));
|
||||
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte", () => ({ default: {} }));
|
||||
@@ -114,6 +117,7 @@ function createPanelElement(): HTMLElement {
|
||||
|
||||
afterEach(() => {
|
||||
runtime.buttonClasses.length = 0;
|
||||
runtime.clickHandlers.length = 0;
|
||||
runtime.panels.length = 0;
|
||||
runtime.settingClasses.length = 0;
|
||||
vi.clearAllMocks();
|
||||
@@ -148,4 +152,93 @@ describe("paneRemoteConfig", () => {
|
||||
|
||||
expect(runtime.panels[0].destroy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("applies an internal Metadata preference change without scheduling setup initialisation", async () => {
|
||||
const originalSettings = {
|
||||
encrypt: true,
|
||||
passphrase: "passphrase",
|
||||
E2EEAlgorithm: "v2",
|
||||
usePathObfuscation: true,
|
||||
encryptInternalMetadata: false,
|
||||
remoteConfigurations: {},
|
||||
};
|
||||
const setupManager = {
|
||||
onlyE2EEConfiguration: vi.fn(async () => {
|
||||
host.core.settings.encryptInternalMetadata = true;
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
const host = {
|
||||
editingSettings: { ...originalSettings },
|
||||
initialSettings: { ...originalSettings },
|
||||
core: {
|
||||
settings: { ...originalSettings },
|
||||
getModule: vi.fn(() => setupManager),
|
||||
},
|
||||
lifetimeComponent: { register: vi.fn() },
|
||||
requestUpdate: vi.fn(),
|
||||
};
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (heading === "E2EE Configuration") {
|
||||
callback(createPanelElement());
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers[0]();
|
||||
|
||||
expect(setupManager.onlyE2EEConfiguration).toHaveBeenCalledOnce();
|
||||
expect(host.editingSettings.encryptInternalMetadata).toBe(true);
|
||||
expect(host.initialSettings.encryptInternalMetadata).toBe(true);
|
||||
expect(host.requestUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("copies applied ID derivation settings into both dialogue buffers", async () => {
|
||||
const nextIdKey = "ab".repeat(32);
|
||||
const originalSettings = {
|
||||
encrypt: true,
|
||||
passphrase: "passphrase",
|
||||
E2EEAlgorithm: "v2",
|
||||
usePathObfuscation: true,
|
||||
encryptInternalMetadata: false,
|
||||
idDerivationVersion: 0,
|
||||
idDerivationKey: "",
|
||||
remoteConfigurations: {},
|
||||
};
|
||||
const setupManager = {
|
||||
onlyE2EEConfiguration: vi.fn(() => {
|
||||
host.core.settings.idDerivationVersion = 1;
|
||||
host.core.settings.idDerivationKey = nextIdKey;
|
||||
return Promise.resolve(false);
|
||||
}),
|
||||
};
|
||||
const host = {
|
||||
editingSettings: { ...originalSettings },
|
||||
initialSettings: { ...originalSettings },
|
||||
core: {
|
||||
settings: { ...originalSettings },
|
||||
getModule: vi.fn(() => setupManager),
|
||||
},
|
||||
lifetimeComponent: { register: vi.fn() },
|
||||
requestUpdate: vi.fn(),
|
||||
};
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (heading === "E2EE Configuration") {
|
||||
callback(createPanelElement());
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers[0]();
|
||||
|
||||
expect(host.editingSettings.idDerivationVersion).toBe(1);
|
||||
expect(host.editingSettings.idDerivationKey).toBe(nextIdKey);
|
||||
expect(host.initialSettings.idDerivationVersion).toBe(1);
|
||||
expect(host.initialSettings.idDerivationKey).toBe(nextIdKey);
|
||||
expect(host.requestUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ export function getE2EEConfigSummary(setting: ObsidianLiveSyncSettings, showAdva
|
||||
export function getSummaryFromPartialSettings(setting: Partial<ObsidianLiveSyncSettings>, showAdvanced = false) {
|
||||
const outputSummary: Record<string, string> = {};
|
||||
for (const key of Object.keys(setting) as (keyof ObsidianLiveSyncSettings)[]) {
|
||||
if (key === "idDerivationKey" || key === "encryptedIdDerivationKey") continue;
|
||||
const config = getConfig(key as AllSettingItemKey);
|
||||
if (!config) continue;
|
||||
if (config.isAdvanced && !showAdvanced) continue;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
type BucketSyncSetting,
|
||||
type EncryptionSettings,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PSyncSetting,
|
||||
LOG_LEVEL_NOTICE,
|
||||
@@ -36,6 +35,7 @@ import type {
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteCouchDBInitialData,
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteE2EEInitialData,
|
||||
SetupRemoteP2PInitialData,
|
||||
SetupRemoteP2PResultType,
|
||||
SetupRemoteResultType,
|
||||
@@ -58,6 +58,20 @@ function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings):
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseImportedIdDerivationSettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
|
||||
// Setup URIs are complete imports even when their encoder omitted default-valued fields.
|
||||
// Fill each missing half so a receiving device cannot supply the unrelated saved key.
|
||||
return {
|
||||
...settings,
|
||||
idDerivationVersion: Object.prototype.hasOwnProperty.call(settings, "idDerivationVersion")
|
||||
? settings.idDerivationVersion
|
||||
: 0,
|
||||
idDerivationKey: Object.prototype.hasOwnProperty.call(settings, "idDerivationKey")
|
||||
? settings.idDerivationKey
|
||||
: "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User modes for onboarding and setup
|
||||
*/
|
||||
@@ -219,7 +233,7 @@ export class SetupManager extends AbstractModule {
|
||||
return false;
|
||||
}
|
||||
this._log("Setup URI dialog closed.", LOG_LEVEL_VERBOSE);
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
|
||||
return await this.onConfirmApplySettingsFromWizard(normaliseImportedIdDerivationSettings(newSetting), userMode);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,14 +342,44 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns
|
||||
*/
|
||||
async onlyE2EEConfiguration(userMode: UserMode, currentSetting: ObsidianLiveSyncSettings): Promise<boolean> {
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteE2EEResultType, EncryptionSettings>(
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteE2EEInitialData
|
||||
>(
|
||||
SetupRemoteE2EE,
|
||||
currentSetting
|
||||
{ settings: currentSetting, newVault: userMode === UserMode.NewUser }
|
||||
);
|
||||
if (e2eeConf === "cancelled") {
|
||||
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
const onlyInternalMetadataPreferenceChanged =
|
||||
currentSetting.encryptInternalMetadata !== e2eeConf.encryptInternalMetadata &&
|
||||
currentSetting.encrypt === e2eeConf.encrypt &&
|
||||
currentSetting.passphrase === e2eeConf.passphrase &&
|
||||
currentSetting.E2EEAlgorithm === e2eeConf.E2EEAlgorithm &&
|
||||
currentSetting.usePathObfuscation === e2eeConf.usePathObfuscation &&
|
||||
currentSetting.idDerivationVersion === e2eeConf.idDerivationVersion &&
|
||||
currentSetting.idDerivationKey === e2eeConf.idDerivationKey;
|
||||
if (userMode === UserMode.Update && onlyInternalMetadataPreferenceChanged) {
|
||||
if (e2eeConf.encryptInternalMetadata && currentSetting.remoteType === REMOTE_COUCHDB) {
|
||||
const proceed = "Enable without rebuilding — update every other device first";
|
||||
const choice = await this.core.confirm.askSelectStringDialogue(
|
||||
"A manual remote Rebuild is strongly recommended to protect existing file properties. " +
|
||||
"Before continuing without rebuilding, update every other synchronising device to a version " +
|
||||
"which supports this option, including devices currently running LiveSync. " +
|
||||
"Existing properties remain unchanged until they are rewritten or rebuilt.",
|
||||
[proceed, "Cancel"],
|
||||
{ title: "Encrypt internal file Properties", defaultAction: "Cancel" }
|
||||
);
|
||||
if (choice !== proceed) return false;
|
||||
}
|
||||
await this.services.setting.applyPartial(
|
||||
{ encryptInternalMetadata: e2eeConf.encryptInternalMetadata },
|
||||
true
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const newSetting = {
|
||||
...currentSetting,
|
||||
...e2eeConf,
|
||||
@@ -350,9 +394,12 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns
|
||||
*/
|
||||
async onConfigureManually(originalSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteE2EEResultType, EncryptionSettings>(
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteE2EEInitialData
|
||||
>(
|
||||
SetupRemoteE2EE,
|
||||
originalSetting
|
||||
{ settings: originalSetting, newVault: userMode === UserMode.NewUser }
|
||||
);
|
||||
if (e2eeConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
@@ -496,7 +543,13 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns Promise that resolves to true if settings applied successfully, false otherwise
|
||||
*/
|
||||
async decodeQR(qr: string) {
|
||||
const newSettings = decodeSettingsFromQRCodeData(qr);
|
||||
let newSettings: ObsidianLiveSyncSettings;
|
||||
try {
|
||||
newSettings = normaliseImportedIdDerivationSettings(decodeSettingsFromQRCodeData(qr));
|
||||
} catch {
|
||||
this._log("The QR configuration could not be decoded or contains unsupported settings.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
return await this.onConfirmApplySettingsFromWizard(newSettings, UserMode.Unknown);
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,58 @@ describe("SetupManager", () => {
|
||||
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
|
||||
});
|
||||
|
||||
it("compatibility: treats omitted ID derivation fields in a Setup URI as legacy defaults", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
const savedKey = "12".repeat(32);
|
||||
setting.settings = {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
idDerivationVersion: 1,
|
||||
idDerivationKey: savedKey,
|
||||
};
|
||||
const imported = {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
} as Partial<ObsidianLiveSyncSettings>;
|
||||
delete imported.idDerivationVersion;
|
||||
delete imported.idDerivationKey;
|
||||
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(imported).mockResolvedValueOnce("cancelled");
|
||||
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://legacy-settings");
|
||||
|
||||
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
|
||||
expect(mergedSettings.idDerivationVersion).toBe(0);
|
||||
expect(mergedSettings.idDerivationKey).toBe("");
|
||||
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
|
||||
});
|
||||
|
||||
it("does not inherit the missing half of a partially present Setup URI ID configuration", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
const savedKey = "34".repeat(32);
|
||||
setting.settings = {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
idDerivationVersion: 1,
|
||||
idDerivationKey: savedKey,
|
||||
};
|
||||
const imported = {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
idDerivationVersion: 1,
|
||||
} as Partial<ObsidianLiveSyncSettings>;
|
||||
delete imported.idDerivationKey;
|
||||
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(imported).mockResolvedValueOnce("cancelled");
|
||||
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://partial-settings");
|
||||
|
||||
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
|
||||
expect(mergedSettings.idDerivationVersion).toBe(1);
|
||||
expect(mergedSettings.idDerivationKey).toBe("");
|
||||
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
|
||||
});
|
||||
|
||||
it("compatibility: normalises imported flat remote settings from QR data before applying", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(createLegacyRemoteSetting());
|
||||
@@ -208,6 +260,79 @@ describe("SetupManager", () => {
|
||||
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
|
||||
});
|
||||
|
||||
it("compatibility: applies legacy defaults when QR data omits ID derivation fields", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
const savedKey = "56".repeat(32);
|
||||
setting.settings = {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
idDerivationVersion: 1,
|
||||
idDerivationKey: savedKey,
|
||||
};
|
||||
const imported = { ...createLegacyRemoteSetting(), isConfigured: true } as Partial<ObsidianLiveSyncSettings>;
|
||||
delete imported.idDerivationVersion;
|
||||
delete imported.idDerivationKey;
|
||||
vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(imported as ObsidianLiveSyncSettings);
|
||||
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
|
||||
|
||||
await manager.decodeQR("qr-data");
|
||||
|
||||
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
|
||||
expect(mergedSettings.idDerivationVersion).toBe(0);
|
||||
expect(mergedSettings.idDerivationKey).toBe("");
|
||||
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
|
||||
});
|
||||
|
||||
it("rejects invalid QR settings before applying them", async () => {
|
||||
const { manager, setting } = createSetupManager();
|
||||
vi.mocked(decodeSettingsFromQRCodeData).mockImplementationOnce(() => {
|
||||
throw new Error("Invalid ID derivation key");
|
||||
});
|
||||
const applyExternalSettings = vi.spyOn(setting, "applyExternalSettings");
|
||||
|
||||
await expect(manager.decodeQR("invalid-qr")).resolves.toBe(false);
|
||||
expect(applyExternalSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires the normal Fetch choice when ID derivation changes with the Metadata preference", async () => {
|
||||
const { manager, setting, dialogManager, core } = createSetupManager();
|
||||
const currentSettings: ObsidianLiveSyncSettings = {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
encrypt: true,
|
||||
passphrase: "e2ee-passphrase",
|
||||
usePathObfuscation: true,
|
||||
encryptInternalMetadata: false,
|
||||
idDerivationVersion: 0,
|
||||
idDerivationKey: "",
|
||||
};
|
||||
const nextIdKey = "78".repeat(32);
|
||||
setting.settings = currentSettings;
|
||||
const applyPartial = vi.spyOn(setting, "applyPartial");
|
||||
core.confirm = {
|
||||
askSelectStringDialogue: vi.fn(() =>
|
||||
Promise.resolve("Enable without rebuilding — update every other device first")
|
||||
),
|
||||
};
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce({
|
||||
...currentSettings,
|
||||
encryptInternalMetadata: true,
|
||||
idDerivationVersion: 1,
|
||||
idDerivationKey: nextIdKey,
|
||||
})
|
||||
.mockResolvedValueOnce("existing-user")
|
||||
.mockResolvedValueOnce("apply");
|
||||
|
||||
await manager.onlyE2EEConfiguration(UserMode.Update, currentSettings);
|
||||
|
||||
expect(applyPartial).not.toHaveBeenCalled();
|
||||
expect(core.rebuilder.scheduleFetch).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(setting.currentSettings().idDerivationVersion).toBe(1);
|
||||
expect(setting.currentSettings().idDerivationKey).toBe(nextIdKey);
|
||||
});
|
||||
|
||||
it("reserves Rebuild before saving a new-user configuration", async () => {
|
||||
const { manager, setting, dialogManager, core } = createSetupManager();
|
||||
setting.settings = { ...setting.currentSettings(), isConfigured: false };
|
||||
@@ -659,3 +784,23 @@ describe("SetupManager", () => {
|
||||
expect(setting.currentSettings().P2P_ActiveRemoteConfigurationId).toBe("existing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("internal Metadata configuration", () => {
|
||||
it.each([true, false])(
|
||||
"applies the preference only after accepting the no-Rebuild warning (%s)",
|
||||
async (accept) => {
|
||||
const { manager, setting, dialogManager, core } = createSetupManager();
|
||||
const current = { ...setting.settings, encryptInternalMetadata: false, remoteType: REMOTE_COUCHDB };
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValue({ ...current, encryptInternalMetadata: true });
|
||||
const ask = vi.fn(async (_message: string, choices: string[]) => (accept ? choices[0] : "Cancel"));
|
||||
core.confirm = { askSelectStringDialogue: ask };
|
||||
const apply = vi.spyOn(setting, "applyPartial").mockResolvedValue(undefined);
|
||||
await expect(manager.onlyE2EEConfiguration(UserMode.Update, current)).resolves.toBe(accept);
|
||||
expect(ask.mock.calls[0][1][0]).toContain("update every other device first");
|
||||
expect(ask.mock.calls[0][0]).toContain("currently running LiveSync");
|
||||
expect(apply).toHaveBeenCalledTimes(accept ? 1 : 0);
|
||||
expect(core.rebuilder.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(core.rebuilder.scheduleFetch).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,38 +13,144 @@
|
||||
E2EEAlgorithms,
|
||||
type EncryptionSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
deriveIdKey,
|
||||
deriveOrImportIdKey,
|
||||
formatIdRecoveryCode,
|
||||
ID_DERIVATION_VERSION,
|
||||
ID_RECOVERY_CODE_PREFIX,
|
||||
} from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { onMount } from "svelte";
|
||||
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import { copyTo, pickEncryptionSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { TYPE_CANCELLED, type SetupRemoteE2EEResultType } from "./setupDialogTypes";
|
||||
import {
|
||||
TYPE_CANCELLED,
|
||||
type SetupRemoteE2EEInitialData,
|
||||
type SetupRemoteE2EEResultType,
|
||||
} from "./setupDialogTypes";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
type Props = GuestDialogProps<SetupRemoteE2EEResultType, EncryptionSettings>;
|
||||
type Props = GuestDialogProps<SetupRemoteE2EEResultType, SetupRemoteE2EEInitialData>;
|
||||
type IdConfigurationChoice = "keep" | "random" | "custom";
|
||||
type IdCustomChoice = "passphrase" | "source" | "recovery";
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
let default_encryption: EncryptionSettings = {
|
||||
encrypt: true,
|
||||
passphrase: "",
|
||||
E2EEAlgorithm: DEFAULT_SETTINGS.E2EEAlgorithm,
|
||||
usePathObfuscation: true,
|
||||
} as EncryptionSettings;
|
||||
encryptInternalMetadata: true,
|
||||
idDerivationVersion: 0,
|
||||
idDerivationKey: "",
|
||||
};
|
||||
|
||||
let encryptionSettings = $state<EncryptionSettings>({ ...default_encryption });
|
||||
let newVault = $state(false);
|
||||
let idConfigurationChoice = $state<IdConfigurationChoice>("keep");
|
||||
let idCustomChoice = $state<IdCustomChoice>("source");
|
||||
let idDerivationSource = $state("");
|
||||
let idDerivationError = $state("");
|
||||
let recoveryCodeVisible = $state(false);
|
||||
let recoveryCodeCopied = $state(false);
|
||||
|
||||
const idDerivationConfigured = $derived(
|
||||
encryptionSettings.idDerivationVersion === ID_DERIVATION_VERSION &&
|
||||
typeof encryptionSettings.idDerivationKey === "string" &&
|
||||
encryptionSettings.idDerivationKey.length > 0
|
||||
);
|
||||
const recoveryCode = $derived.by(() =>
|
||||
idDerivationConfigured ? formatIdRecoveryCode(encryptionSettings.idDerivationKey) : ""
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
if (getInitialData) {
|
||||
const initialData = getInitialData();
|
||||
if (initialData) {
|
||||
copyTo(initialData, encryptionSettings);
|
||||
copyTo(initialData.settings, encryptionSettings);
|
||||
newVault = initialData.newVault;
|
||||
}
|
||||
}
|
||||
idConfigurationChoice = !idDerivationConfigured && newVault ? "random" : "keep";
|
||||
});
|
||||
let e2eeValid = $derived.by(() => {
|
||||
if (!encryptionSettings.encrypt) return true;
|
||||
return encryptionSettings.passphrase.trim().length >= 1;
|
||||
});
|
||||
let canEncryptInternalMetadata = $derived(
|
||||
encryptionSettings.encrypt &&
|
||||
encryptionSettings.E2EEAlgorithm === E2EEAlgorithms.V2 &&
|
||||
encryptionSettings.usePathObfuscation
|
||||
);
|
||||
|
||||
function commit() {
|
||||
setResult(pickEncryptionSettings(encryptionSettings));
|
||||
function resetIdDerivationSource() {
|
||||
idDerivationSource = "";
|
||||
idDerivationError = "";
|
||||
}
|
||||
|
||||
function toggleEncryption(enabled: boolean) {
|
||||
encryptionSettings.encrypt = enabled;
|
||||
if (!enabled) resetIdDerivationSource();
|
||||
}
|
||||
|
||||
function selectIdConfiguration() {
|
||||
recoveryCodeVisible = false;
|
||||
recoveryCodeCopied = false;
|
||||
resetIdDerivationSource();
|
||||
}
|
||||
|
||||
function selectIdCustomSource() {
|
||||
resetIdDerivationSource();
|
||||
}
|
||||
|
||||
async function copyRecoveryCode() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(recoveryCode);
|
||||
recoveryCodeCopied = true;
|
||||
} catch {
|
||||
idDerivationError = translateMessage("The recovery code could not be copied. Select and copy the visible code instead.");
|
||||
}
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
idDerivationError = "";
|
||||
const result = pickEncryptionSettings(encryptionSettings);
|
||||
|
||||
if (encryptionSettings.encrypt && idConfigurationChoice !== "keep") {
|
||||
let source = idDerivationSource;
|
||||
if (idConfigurationChoice === "random") {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
source = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
} else if (idCustomChoice === "passphrase") {
|
||||
source = encryptionSettings.passphrase;
|
||||
}
|
||||
if (source.length === 0) {
|
||||
if (!idDerivationConfigured) {
|
||||
idDerivationError = translateMessage("An ID source is required to enable this option.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
result.idDerivationKey =
|
||||
idConfigurationChoice === "custom" && idCustomChoice !== "passphrase"
|
||||
? await importOrDeriveEnteredIdKey(source, idCustomChoice)
|
||||
: await deriveIdKey(source);
|
||||
result.idDerivationVersion = ID_DERIVATION_VERSION;
|
||||
} catch {
|
||||
idDerivationError = translateMessage("The ID source or recovery code is invalid. Check it and try again.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
idDerivationSource = "";
|
||||
setResult(result);
|
||||
}
|
||||
|
||||
async function importOrDeriveEnteredIdKey(source: string, choice: IdCustomChoice): Promise<string> {
|
||||
if (choice === "recovery" && !source.trim().startsWith(ID_RECOVERY_CODE_PREFIX)) {
|
||||
throw new Error("An ID recovery code is required.");
|
||||
}
|
||||
return await deriveOrImportIdKey(source);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -52,7 +158,11 @@
|
||||
<DialogHeader title={translateMessage("End-to-End Encryption")} />
|
||||
<Guidance>{translateMessage("Please configure your end-to-end encryption settings.")}</Guidance>
|
||||
<InputRow label={translateMessage("End-to-End Encryption")}>
|
||||
<input type="checkbox" bind:checked={encryptionSettings.encrypt} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={encryptionSettings.encrypt}
|
||||
onchange={(event) => toggleEncryption(event.currentTarget.checked)}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote title={translateMessage("Strongly Recommended")}>
|
||||
{translateMessage(
|
||||
@@ -87,6 +197,182 @@
|
||||
</InfoNote>
|
||||
{/if}
|
||||
|
||||
<fieldset class="sls-id-choices" disabled={!encryptionSettings.encrypt}>
|
||||
<legend>{translateMessage("ID generation")}</legend>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-derivation-choice"
|
||||
value="keep"
|
||||
bind:group={idConfigurationChoice}
|
||||
onchange={selectIdConfiguration}
|
||||
/>
|
||||
<div class="sls-id-choice-text">
|
||||
<span>{translateMessage("Keep current configuration")}</span>
|
||||
<small class="sls-current-id-configuration">
|
||||
{#if idDerivationConfigured}
|
||||
{translateMessage(
|
||||
encryptionSettings.encrypt
|
||||
? "Current configuration: a saved ID key is used."
|
||||
: "Current configuration: the saved ID key is retained while E2EE is off."
|
||||
)}
|
||||
{:else}
|
||||
{translateMessage(
|
||||
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase."
|
||||
)}
|
||||
{/if}
|
||||
</small>
|
||||
</div>
|
||||
</label>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-derivation-choice"
|
||||
value="random"
|
||||
bind:group={idConfigurationChoice}
|
||||
onchange={selectIdConfiguration}
|
||||
/>
|
||||
<span>{translateMessage("Generate a random ID key")}</span>
|
||||
</label>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-derivation-choice"
|
||||
value="custom"
|
||||
bind:group={idConfigurationChoice}
|
||||
onchange={selectIdConfiguration}
|
||||
/>
|
||||
<span>{translateMessage("Set an ID key")}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
{#if encryptionSettings.encrypt && idConfigurationChoice === "keep" && !idDerivationConfigured}
|
||||
<InfoNote warning>
|
||||
{translateMessage("Changing the E2EE passphrase changes IDs generated by the legacy configuration.")}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
{#if (encryptionSettings.encrypt && idConfigurationChoice !== "keep") || idDerivationConfigured}
|
||||
{#if encryptionSettings.encrypt}
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically."
|
||||
)}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
{#if idDerivationConfigured}
|
||||
<InfoNote title={translateMessage("Configured")}>
|
||||
{translateMessage("The saved ID key is configured. Its source cannot be shown again.")}
|
||||
</InfoNote>
|
||||
<button type="button" onclick={() => (recoveryCodeVisible = !recoveryCodeVisible)}>
|
||||
{translateMessage(recoveryCodeVisible ? "Hide current recovery code" : "Show current recovery code")}
|
||||
</button>
|
||||
{#if recoveryCodeVisible}
|
||||
<InputRow label={translateMessage("Current ID recovery code")}>
|
||||
<input type="text" readonly value={recoveryCode} aria-label={translateMessage("Current ID recovery code")} />
|
||||
<button type="button" onclick={copyRecoveryCode}>{translateMessage("Copy recovery code")}</button>
|
||||
</InputRow>
|
||||
{#if recoveryCodeCopied}
|
||||
<InfoNote>{translateMessage("Recovery code copied.")}</InfoNote>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if encryptionSettings.encrypt}
|
||||
{#if idConfigurationChoice === "custom"}
|
||||
<fieldset class="sls-id-choices sls-id-custom-choices">
|
||||
<legend>{translateMessage("How to set the ID key")}</legend>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-custom-choice"
|
||||
value="passphrase"
|
||||
bind:group={idCustomChoice}
|
||||
onchange={selectIdCustomSource}
|
||||
/>
|
||||
<span>{translateMessage("Derive from current E2EE passphrase")}</span>
|
||||
</label>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-custom-choice"
|
||||
value="source"
|
||||
bind:group={idCustomChoice}
|
||||
onchange={selectIdCustomSource}
|
||||
/>
|
||||
<span>{translateMessage("Enter an ID source")}</span>
|
||||
</label>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-custom-choice"
|
||||
value="recovery"
|
||||
bind:group={idCustomChoice}
|
||||
onchange={selectIdCustomSource}
|
||||
/>
|
||||
<span>{translateMessage("Import an ID recovery code")}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
{#if idCustomChoice === "source" || idCustomChoice === "recovery"}
|
||||
<InputRow
|
||||
label={translateMessage(idCustomChoice === "source" ? "ID source" : "ID recovery code")}
|
||||
>
|
||||
<Password
|
||||
name="id-derivation-source"
|
||||
placeholder={translateMessage(
|
||||
idCustomChoice === "source" ? "Enter an ID source" : "Enter an ID recovery code"
|
||||
)}
|
||||
bind:value={idDerivationSource}
|
||||
/>
|
||||
</InputRow>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if idDerivationConfigured && idConfigurationChoice !== "keep"}
|
||||
<InfoNote>
|
||||
{translateMessage("The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.")}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
{#if idConfigurationChoice === "custom" && idCustomChoice === "source"}
|
||||
<InfoNote>
|
||||
{translateMessage("Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.")}
|
||||
</InfoNote>
|
||||
{:else if idConfigurationChoice === "custom" && idCustomChoice === "recovery"}
|
||||
<InfoNote>
|
||||
{translateMessage("Paste a tagged recovery code from an existing device to restore the same ID key.")}
|
||||
</InfoNote>
|
||||
{:else if idConfigurationChoice === "random"}
|
||||
<InfoNote warning>
|
||||
{translateMessage("For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.")}
|
||||
</InfoNote>
|
||||
{:else if idConfigurationChoice === "custom" && idCustomChoice === "passphrase"}
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead."
|
||||
)}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
{#if idDerivationConfigured && idConfigurationChoice === "custom" && idCustomChoice !== "passphrase"}
|
||||
<InfoNote>{translateMessage("Leave this input empty to keep the saved ID key.")}</InfoNote>
|
||||
{/if}
|
||||
{/if}
|
||||
<InfoNote error visible={idDerivationError !== ""}>{idDerivationError}</InfoNote>
|
||||
{/if}
|
||||
|
||||
<InputRow label="Encrypt internal file Properties">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={encryptionSettings.encryptInternalMetadata}
|
||||
disabled={!canEncryptInternalMetadata}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
This option encrypts file properties used by Hidden File Sync and Customisation Sync.
|
||||
<br />
|
||||
It applies only to CouchDB and requires End-to-End Encryption, the V2 algorithm, and Property Encryption
|
||||
(Obfuscate Properties). The remote type is selected later in this setup wizard.
|
||||
<br />
|
||||
It protects properties written after the option is enabled; existing properties are not rewritten. A manual remote
|
||||
Rebuild is strongly recommended to protect existing properties. Update every other synchronising device to a compatible
|
||||
version before enabling this option, including devices currently running LiveSync.
|
||||
</InfoNote>
|
||||
|
||||
<ExtraItems title={translateMessage("Advanced")}>
|
||||
<InputRow label={translateMessage("Encryption Algorithm")}>
|
||||
<select bind:value={encryptionSettings.E2EEAlgorithm} disabled={!encryptionSettings.encrypt}>
|
||||
@@ -138,4 +424,41 @@
|
||||
width: auto;
|
||||
min-width: 8em;
|
||||
}
|
||||
.sls-id-choices {
|
||||
border: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35em;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.sls-id-choices legend {
|
||||
margin-bottom: 0.35em;
|
||||
}
|
||||
.sls-id-choices:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.sls-id-custom-choices {
|
||||
margin-left: 1.5em;
|
||||
}
|
||||
.sls-id-choice {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.sls-id-choice input[type="radio"] {
|
||||
flex: none;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
.sls-id-choice-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sls-current-id-configuration {
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
font-size: var(--font-ui-smaller);
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import TurnConfiguration from "@/features/P2PSync/TurnConfiguration.svelte";
|
||||
import { validateManagedTurnSettings } from "@/integrations/turnSettings";
|
||||
// import { delay } from "octagonal-wheels/promises";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
@@ -15,7 +17,7 @@
|
||||
P2PMessageSizePresets,
|
||||
PREFERRED_BASE,
|
||||
RemoteTypes,
|
||||
hasValidP2PTurnServerUrl,
|
||||
hasP2PTurnConfiguration,
|
||||
normaliseP2PConnectionPath,
|
||||
normaliseP2PMaxWirePayloadBytes,
|
||||
type EntryDoc,
|
||||
@@ -27,7 +29,6 @@
|
||||
import { TrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
|
||||
import type { ReplicatorHostEnv } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/types";
|
||||
import {
|
||||
copyTo,
|
||||
generateP2PRoomId,
|
||||
pickP2PSyncSettings,
|
||||
type SimpleStore,
|
||||
@@ -51,7 +52,7 @@
|
||||
const context = getDialogContext();
|
||||
let error = $state("");
|
||||
let connectionPathResetNotice = $state(false);
|
||||
const hasValidTurnServer = $derived(hasValidP2PTurnServerUrl(syncSetting.P2P_turnServers ?? ""));
|
||||
const hasValidTurnServer = $derived(hasP2PTurnConfiguration(syncSetting));
|
||||
type Props = GuestDialogProps<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>;
|
||||
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
@@ -61,7 +62,7 @@
|
||||
connectionProbe = initialData?.connectionProbe;
|
||||
const initialSettings = initialData?.settings;
|
||||
if (initialSettings) {
|
||||
copyTo(initialSettings, syncSetting);
|
||||
syncSetting = pickP2PSyncSettings(initialSettings);
|
||||
}
|
||||
const initialPeerName = (initialSettings?.P2P_DevicePeerName ?? "").trim();
|
||||
if (initialPeerName !== "") {
|
||||
@@ -100,12 +101,14 @@
|
||||
async function checkConnection() {
|
||||
try {
|
||||
processing = true;
|
||||
const sourceError = validateManagedTurnSettings(syncSetting);
|
||||
if (sourceError) return sourceError;
|
||||
const trialRemoteSetting = generateSetting();
|
||||
const admission = connectionProbe;
|
||||
if (!admission) {
|
||||
throw new Error("The P2P Setup connection probe is not available.");
|
||||
}
|
||||
const result = await coordinateP2PSetupConnectionProbe(admission, trialRemoteSetting, async () => {
|
||||
const result = await coordinateP2PSetupConnectionProbe(admission, trialRemoteSetting, async (signallingSettings) => {
|
||||
const map = new Map<string, unknown>();
|
||||
const store = {
|
||||
get: (key: string) => {
|
||||
@@ -133,7 +136,7 @@
|
||||
const env: ReplicatorHostEnv = {
|
||||
events: context.context.events,
|
||||
translate: context.context.translate,
|
||||
settings: trialRemoteSetting,
|
||||
settings: signallingSettings,
|
||||
processReplicatedDocs: async (_docs: PouchDB.Core.ExistingDocument<EntryDoc>[]) => {
|
||||
return;
|
||||
},
|
||||
@@ -204,6 +207,8 @@
|
||||
}
|
||||
}
|
||||
function commit() {
|
||||
error = validateManagedTurnSettings(syncSetting) ?? "";
|
||||
if (error) return;
|
||||
const setting = pickP2PSyncSettings(generateSetting());
|
||||
setResult(setting);
|
||||
}
|
||||
@@ -215,7 +220,8 @@
|
||||
syncSetting.P2P_relays.trim() !== "" &&
|
||||
syncSetting.P2P_roomID.trim() !== "" &&
|
||||
syncSetting.P2P_passphrase.trim() !== "" &&
|
||||
(syncSetting.P2P_DevicePeerName ?? "").trim() !== ""
|
||||
(syncSetting.P2P_DevicePeerName ?? "").trim() !== "" &&
|
||||
validateManagedTurnSettings(syncSetting) === undefined
|
||||
);
|
||||
});
|
||||
</script>
|
||||
@@ -339,24 +345,24 @@
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"TURN relay only is available when at least one valid TURN server URL is configured under Advanced Settings."
|
||||
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InfoNote notice visible={connectionPathResetNotice}>
|
||||
{translateMessage(
|
||||
"TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic."
|
||||
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic."
|
||||
)}
|
||||
</InfoNote>
|
||||
</ExtraItems>
|
||||
<ExtraItems title={translateMessage("Advanced Settings")}>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P connections. In most cases, you can leave these fields blank."
|
||||
"Configure TURN when a direct connection cannot be established or when you select TURN relay only."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InfoNote warning>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust."
|
||||
"WebRTC encrypts data between your devices, including when it passes through TURN. The TURN provider cannot read the transferred data. It can see network addresses and traffic volume."
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/p2p.md#signalling-relay-and-turn-server"
|
||||
@@ -364,34 +370,7 @@
|
||||
rel="noopener noreferrer">{translateMessage("Learn more about signalling and TURN")}</a
|
||||
>.
|
||||
</InfoNote>
|
||||
<InputRow label={translateMessage("TURN Server URLs (comma-separated)")}>
|
||||
<textarea
|
||||
name="p2p-turn-servers"
|
||||
placeholder="turn:turn.example.com:3478,turn:turn.example.com:443"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.P2P_turnServers}
|
||||
rows="5"
|
||||
></textarea>
|
||||
</InputRow>
|
||||
<InputRow label={translateMessage("TURN Username")}>
|
||||
<input
|
||||
type="text"
|
||||
name="p2p-turn-username"
|
||||
placeholder={translateMessage("Enter TURN username")}
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.P2P_turnUsername}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label={translateMessage("TURN Credential")}>
|
||||
<Password
|
||||
name="p2p-turn-credential"
|
||||
placeholder={translateMessage("Enter TURN credential")}
|
||||
bind:value={syncSetting.P2P_turnCredential}
|
||||
/>
|
||||
</InputRow>
|
||||
<TurnConfiguration bind:settings={syncSetting} />
|
||||
</ExtraItems>
|
||||
<InfoNote error visible={error !== ""}>
|
||||
{error}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { configURIBase } from "@/common/types";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
@@ -10,7 +9,7 @@
|
||||
import Password from "@/modules/services/LiveSyncUI/components/Password.svelte";
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
|
||||
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import { TYPE_CANCELLED, type UseSetupURIResultType } from "./setupDialogTypes";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
@@ -30,7 +29,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
const seemsValid = $derived.by(() => setupURI.startsWith(configURIBase));
|
||||
const seemsValid = $derived(setupURI.startsWith(configURIBase));
|
||||
async function processSetupURI() {
|
||||
error = "";
|
||||
if (!seemsValid) return;
|
||||
@@ -39,11 +38,8 @@
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const settingPieces = setupURI.substring(configURIBase.length);
|
||||
const encodedConfig = decodeURIComponent(settingPieces);
|
||||
const newConf = (await JSON.parse(
|
||||
await decryptString(encodedConfig, passphrase)
|
||||
)) as ObsidianLiveSyncSettings;
|
||||
const newConf = await decodeSettingsFromSetupURI(setupURI.trim(), passphrase);
|
||||
if (!newConf) throw new Error("Invalid Setup URI settings");
|
||||
setResult(newConf);
|
||||
// Logger("Settings imported successfully", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type P2PConnectionProbeAdmission,
|
||||
type P2PConnectionProbeSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { P2PConnectionPaths, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
export type P2PSetupConnectionProbeResult =
|
||||
| { readonly ok: true }
|
||||
@@ -20,12 +21,25 @@ export interface P2PSetupConnectionProbe {
|
||||
}
|
||||
|
||||
/** Interpret the stable P2P owner's admission without constructing transport eagerly. */
|
||||
export async function coordinateP2PSetupConnectionProbe(
|
||||
export async function coordinateP2PSetupConnectionProbe<T extends P2PConnectionProbeSettings>(
|
||||
admission: P2PConnectionProbeAdmission,
|
||||
trialSettings: P2PConnectionProbeSettings,
|
||||
runOwnedTrial: () => Promise<P2PSetupConnectionProbeResult>
|
||||
trialSettings: T,
|
||||
runOwnedTrial: (settings: T) => Promise<P2PSetupConnectionProbeResult>
|
||||
): Promise<P2PSetupConnectionProbeResult> {
|
||||
const settlement = await admission.run(trialSettings, runOwnedTrial);
|
||||
const settlement = await admission.run(trialSettings, () => {
|
||||
// This trial checks signalling only; TURN allocation belongs to an actual connection.
|
||||
const settings: T & Partial<P2PSyncSetting> = { ...trialSettings };
|
||||
delete settings.P2P_managedType;
|
||||
delete settings.P2P_managedId;
|
||||
delete settings.P2P_managedToken;
|
||||
delete settings.P2P_iceServers;
|
||||
delete settings.P2P_iceServersExpiresAt;
|
||||
settings.P2P_turnServers = "";
|
||||
settings.P2P_turnUsername = "";
|
||||
settings.P2P_turnCredential = "";
|
||||
settings.P2P_connectionPath = P2PConnectionPaths.Automatic;
|
||||
return runOwnedTrial(settings);
|
||||
});
|
||||
if (settlement.status === "observed-active") return { ok: true };
|
||||
if (settlement.status === "blocked") {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ACTIVE_P2P_RELAY_BINDING_CONFLICT, type P2PConnectionProbeAdmission } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { DEFAULT_SETTINGS, P2PConnectionPaths } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
coordinateP2PSetupConnectionProbe,
|
||||
probeP2PSetupConnection,
|
||||
@@ -7,6 +8,40 @@ import {
|
||||
} from "./p2pSetupConnectionProbe";
|
||||
|
||||
describe("P2P setup connection probe", () => {
|
||||
it("constructs a signalling-only trial when the draft selects managed TURN", async () => {
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
P2P_managedType: "CF",
|
||||
P2P_managedId: "test-key",
|
||||
P2P_managedToken: "test-token",
|
||||
P2P_iceServers: [
|
||||
{ urls: "turn:temporary.example.test", username: "issued-user", credential: "issued-password" },
|
||||
],
|
||||
P2P_iceServersExpiresAt: 123456789,
|
||||
P2P_turnServers: "turn:unused.example.test:3478",
|
||||
P2P_turnUsername: "unused-user",
|
||||
P2P_turnCredential: "unused-password",
|
||||
P2P_connectionPath: P2PConnectionPaths.Relay,
|
||||
};
|
||||
const admission: P2PConnectionProbeAdmission = {
|
||||
run: async (_settings, trial) => ({ status: "trial", result: await trial() }),
|
||||
};
|
||||
const result = await coordinateP2PSetupConnectionProbe(admission, settings, async (trial = settings) => {
|
||||
expect(trial.P2P_managedType).toBeUndefined();
|
||||
expect(trial.P2P_managedToken).toBeUndefined();
|
||||
expect(trial.P2P_iceServers).toBeUndefined();
|
||||
expect(trial.P2P_iceServersExpiresAt).toBeUndefined();
|
||||
expect(trial.P2P_turnServers).toBe("");
|
||||
expect(trial.P2P_turnUsername).toBe("");
|
||||
expect(trial.P2P_turnCredential).toBe("");
|
||||
expect(trial.P2P_connectionPath).toBe(P2PConnectionPaths.Automatic);
|
||||
return { ok: true };
|
||||
});
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(settings.P2P_managedToken).toBe("test-token");
|
||||
expect(settings.P2P_connectionPath).toBe(P2PConnectionPaths.Relay);
|
||||
});
|
||||
|
||||
it("uses a compatible active signalling connection without constructing a trial", async () => {
|
||||
const runOwnedTrial = vi.fn(async (): Promise<P2PSetupConnectionProbeResult> => ({ ok: true }));
|
||||
const admission: P2PConnectionProbeAdmission = {
|
||||
|
||||
@@ -110,6 +110,10 @@ export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | t
|
||||
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
|
||||
|
||||
export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettings;
|
||||
export type SetupRemoteE2EEInitialData = {
|
||||
settings: EncryptionSettings;
|
||||
newVault: boolean;
|
||||
};
|
||||
|
||||
export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
|
||||
|
||||
|
||||
@@ -131,3 +131,50 @@ export function createPaddedCounterLabel(
|
||||
source.offChanged(update);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the disjoint initial and retry chunk-fetch counts with the same
|
||||
* padding and inactive linger behaviour as the other status counters.
|
||||
*/
|
||||
export function createChunkFetchCounterLabel(
|
||||
source: ReactiveValue<{ initial: number; retrying: number }>
|
||||
): DisposableReactiveValue<string> {
|
||||
const initialCount = reactiveSource(0);
|
||||
const retryingCount = reactiveSource(0);
|
||||
const initialLabel = createPaddedCounterLabel(initialCount, "🛄");
|
||||
const retryingLabel = createPaddedCounterLabel(retryingCount, "🔁");
|
||||
const formatted = reactiveSource(`${initialLabel.value}${retryingLabel.value}`);
|
||||
let updatingCounts = false;
|
||||
let disposed = false;
|
||||
|
||||
const updateLabel = () => {
|
||||
if (updatingCounts || disposed) return;
|
||||
formatted.value = `${initialLabel.value}${retryingLabel.value}`;
|
||||
};
|
||||
initialLabel.onChanged(updateLabel);
|
||||
retryingLabel.onChanged(updateLabel);
|
||||
|
||||
const updateCounts = () => {
|
||||
if (disposed) return;
|
||||
updatingCounts = true;
|
||||
try {
|
||||
initialCount.value = source.value.initial;
|
||||
retryingCount.value = source.value.retrying;
|
||||
} finally {
|
||||
updatingCounts = false;
|
||||
updateLabel();
|
||||
}
|
||||
};
|
||||
source.onChanged(updateCounts);
|
||||
updateCounts();
|
||||
|
||||
return asDisposableReactiveValue(formatted, () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
source.offChanged(updateCounts);
|
||||
initialLabel.offChanged(updateLabel);
|
||||
retryingLabel.offChanged(updateLabel);
|
||||
initialLabel.dispose();
|
||||
retryingLabel.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
createChunkFetchCounterLabel,
|
||||
createMinimumVisibleActivityCount,
|
||||
createPaddedCounterLabel,
|
||||
} from "./StatusBarDisplay.ts";
|
||||
@@ -137,3 +138,41 @@ describe("createPaddedCounterLabel", () => {
|
||||
expect(display.value).toBe(" 📄\u20070");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createChunkFetchCounterLabel", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps initial and retry counts separate across an unchanged-total handoff", () => {
|
||||
const counts = reactiveSource({ initial: 2, retrying: 5 });
|
||||
const display = createChunkFetchCounterLabel(counts);
|
||||
const withoutPadding = () => display.value.replace(/\u2007/g, "");
|
||||
const transitionSnapshots: string[] = [];
|
||||
const observeTransitions = () => transitionSnapshots.push(withoutPadding());
|
||||
|
||||
expect(withoutPadding()).toBe(" 🛄2 🔁5");
|
||||
|
||||
display.onChanged(observeTransitions);
|
||||
counts.value = { initial: 0, retrying: 7 };
|
||||
expect(withoutPadding()).toBe(" 🛄0 🔁7");
|
||||
expect(transitionSnapshots).toEqual([" 🛄0 🔁7"]);
|
||||
display.offChanged(observeTransitions);
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1);
|
||||
expect(withoutPadding()).toBe(" 🛄0 🔁7");
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(withoutPadding()).toBe(" 🔁7");
|
||||
|
||||
counts.value = { initial: 0, retrying: 0 };
|
||||
expect(withoutPadding()).toBe(" 🔁0");
|
||||
display.dispose();
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS);
|
||||
counts.value = { initial: 1, retrying: 0 };
|
||||
|
||||
expect(withoutPadding()).toBe(" 🔁0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -190,6 +190,20 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
log: LogFunction,
|
||||
cleanupFlag: () => Promise<void>
|
||||
): Promise<boolean | undefined> {
|
||||
if (host.services.setting.currentSettings().maxMTimeForReflectEvents > 0) {
|
||||
// Simple Fetch reconciles storage with the local database after fetching, past the check which
|
||||
// refuses that scan in remediation mode. Skipping only the scan would restore nothing from most
|
||||
// remotes: reflection of received documents stays suspended while Simple Fetch fetches, so Object
|
||||
// Storage and P2P remotes discard them, and CouchDB Fast Fetch writes them straight into the
|
||||
// database. The detailed flow at least states the restriction and offers to clear it before
|
||||
// rebuilding, instead of quietly reconciling past it.
|
||||
log(
|
||||
"Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
return undefined;
|
||||
}
|
||||
const result = await askSimpleFetchMode(host);
|
||||
if (result === "cancelled") {
|
||||
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
|
||||
@@ -771,6 +771,24 @@ describe("Red Flag Feature", () => {
|
||||
});
|
||||
|
||||
describe("askAndPerformFastSetupOnScheduledFetchAll", () => {
|
||||
it("uses the detailed flow instead of Simple Fetch while remediation mode is active", async () => {
|
||||
const host = createHostMock();
|
||||
const log = createLoggerMock();
|
||||
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
Object.assign(host.mocks.setting.settings, {
|
||||
maxMTimeForReflectEvents: Date.parse("2026-09-01T00:00:00Z"),
|
||||
});
|
||||
|
||||
await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag)).resolves.toBe(
|
||||
undefined
|
||||
);
|
||||
|
||||
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(cleanupFlag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases both reflection suspensions after Fast Setup succeeds", async () => {
|
||||
const host = createHostMock();
|
||||
const log = createLoggerMock();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { assessRemoteFeatureDocument, describeRemoteFeatureRejection } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
SYNCINFO_ID,
|
||||
VER,
|
||||
VERSIONING_DOCID,
|
||||
type AnyEntry,
|
||||
type EntryDoc,
|
||||
type EntryLeaf,
|
||||
@@ -35,7 +36,7 @@ type ReplicateResultProcessorSettings = Pick<
|
||||
>;
|
||||
type ReplicateResultProcessorServices = Pick<
|
||||
LiveSyncBaseCore["services"],
|
||||
"appLifecycle" | "path" | "replication" | "vault"
|
||||
"appLifecycle" | "database" | "path" | "replication" | "vault"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -115,10 +116,27 @@ export class ReplicateResultProcessor {
|
||||
// If true, the processing queue processor bails the loop.
|
||||
private _suspended: boolean = false;
|
||||
|
||||
/**
|
||||
* Whether the application accepts replicated documents being applied.
|
||||
*
|
||||
* Remediation mode refuses the reconciliation scan which readiness depends upon, so the
|
||||
* application stays unready for as long as the modification-time limit is configured.
|
||||
* Applying the received documents is what that mode exists for, and `parseDocumentChange` keeps
|
||||
* each one within the limit, so readiness is not required while the mode is active.
|
||||
*/
|
||||
private get acceptsResultApplication() {
|
||||
if (this.services.appLifecycle.isReady()) return true;
|
||||
if (this.context.currentSettings().maxMTimeForReflectEvents <= 0) return false;
|
||||
// A fetch resets the local database, and a remote which reflects while fetching leaves this
|
||||
// processor unsuspended throughout. A document applied then cannot gather its chunks and is
|
||||
// dropped, so the database itself must still be usable.
|
||||
return this.services.database.isDatabaseReady();
|
||||
}
|
||||
|
||||
public get isSuspended() {
|
||||
return (
|
||||
this._suspended ||
|
||||
!this.services.appLifecycle.isReady() ||
|
||||
!this.acceptsResultApplication ||
|
||||
this.context.currentSettings().suspendParseReplicationResult ||
|
||||
this.services.appLifecycle.isSuspended()
|
||||
);
|
||||
@@ -257,13 +275,14 @@ export class ReplicateResultProcessor {
|
||||
this.log(`Processed chunk: ${shortenId(change._id)}`, LOG_LEVEL_DEBUG);
|
||||
return true;
|
||||
}
|
||||
if (change.type == "versioninfo") {
|
||||
if (change._id === VERSIONING_DOCID || change.type === "versioninfo") {
|
||||
this.log(`Version info document received: ${change._id}`, LOG_LEVEL_VERBOSE);
|
||||
if (change.version > VER) {
|
||||
const assessment = assessRemoteFeatureDocument(change);
|
||||
if (assessment.status !== "supported" && assessment.status !== "older-generation") {
|
||||
// Fence and retire the active publication through its owner.
|
||||
this.context.requestActiveReplicatorRetirement();
|
||||
this.log(
|
||||
`Remote database updated to incompatible version. update your Self-hosted LiveSync plugin.`,
|
||||
`${describeRemoteFeatureRejection(assessment)} Update Self-hosted LiveSync before synchronising.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { VER, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
VERSIONING_DOCID,
|
||||
type EntryDoc,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
isValidFilenameInAndroid,
|
||||
isValidFilenameInWidows,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import {
|
||||
defaultLogger,
|
||||
LOG_LEVEL_DEBUG,
|
||||
@@ -28,8 +37,15 @@ function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
|
||||
|
||||
type SetupOptions = {
|
||||
applicationReady?: boolean;
|
||||
databaseReady?: boolean;
|
||||
maxMTimeForReflectEvents?: number;
|
||||
isValidPath?: (path: string) => boolean;
|
||||
processSynchroniseResult?: (entry: unknown) => Promise<boolean>;
|
||||
setSnapshot?: (key: string, value: unknown) => Promise<unknown>;
|
||||
getSnapshot?: (key: string) => Promise<unknown>;
|
||||
localVersionInfo?: unknown;
|
||||
isTargetFile?: (path: string) => Promise<boolean>;
|
||||
databaseId?: string;
|
||||
};
|
||||
|
||||
function setup(options: SetupOptions = {}) {
|
||||
@@ -38,9 +54,15 @@ function setup(options: SetupOptions = {}) {
|
||||
const runBoundedLocalApplicationActivity = vi.fn(async (task: () => Promise<void>) => await task());
|
||||
const onCloseActiveReplication = vi.fn(async () => true);
|
||||
const isReady = vi.fn(() => options.applicationReady ?? true);
|
||||
const isValidPath = vi.fn(options.isValidPath ?? (() => true));
|
||||
const getDBEntryFromMeta = vi.fn(async (entry: object) => ({ ...entry, data: "x" }));
|
||||
const localPhysicalDatabase = {
|
||||
...(options.databaseId ? { id: vi.fn(async () => options.databaseId) } : {}),
|
||||
} as PouchDB.Database<EntryDoc>;
|
||||
const core = {
|
||||
services: {
|
||||
appLifecycle: { isReady, isSuspended: () => false },
|
||||
database: { isDatabaseReady: () => options.databaseReady ?? true },
|
||||
path: { getPath: (entry: { path: string }) => entry.path },
|
||||
replication: {
|
||||
databaseQueueCount: reactiveSource(0),
|
||||
@@ -52,19 +74,29 @@ function setup(options: SetupOptions = {}) {
|
||||
},
|
||||
replicator: { onCloseActiveReplication, runBoundedLocalApplicationActivity },
|
||||
vault: {
|
||||
isTargetFile: vi.fn(async () => true),
|
||||
isTargetFile: vi.fn(options.isTargetFile ?? (async () => true)),
|
||||
isFileSizeTooLarge: vi.fn(() => false),
|
||||
isValidPath: vi.fn(() => true),
|
||||
isValidPath,
|
||||
},
|
||||
},
|
||||
kvDB: { set: setSnapshot },
|
||||
kvDB: { set: setSnapshot, get: vi.fn(options.getSnapshot ?? (async () => undefined)) },
|
||||
localDatabase: {
|
||||
getRaw: vi.fn(async (id: string) => ({ _id: id, _rev: "1-test" })),
|
||||
getDBEntryFromMeta: vi.fn(async (entry: object) => ({ ...entry, data: "x" })),
|
||||
localDatabase: localPhysicalDatabase,
|
||||
getRaw: vi.fn(async (id: string) => {
|
||||
if (id === VERSIONING_DOCID) {
|
||||
if (options.localVersionInfo === undefined) throw { status: 404 };
|
||||
return options.localVersionInfo;
|
||||
}
|
||||
return { _id: id, _rev: "1-test" };
|
||||
}),
|
||||
getDBEntryFromMeta,
|
||||
},
|
||||
};
|
||||
const processor = new ReplicateResultProcessor({
|
||||
currentSettings: () => ({ maxMTimeForReflectEvents: 0, suspendParseReplicationResult: false }),
|
||||
currentSettings: () => ({
|
||||
maxMTimeForReflectEvents: options.maxMTimeForReflectEvents ?? 0,
|
||||
suspendParseReplicationResult: false,
|
||||
}),
|
||||
getKeyValueDB: () => core.kvDB,
|
||||
getLocalDatabase: () => core.localDatabase,
|
||||
requestActiveReplicatorRetirement: () => {
|
||||
@@ -74,7 +106,12 @@ function setup(options: SetupOptions = {}) {
|
||||
services: core.services,
|
||||
} as never);
|
||||
return {
|
||||
getDBEntryFromMeta,
|
||||
isTargetFile: core.services.vault.isTargetFile,
|
||||
localPhysicalDatabase,
|
||||
localDatabase: core.localDatabase,
|
||||
isReady,
|
||||
isValidPath,
|
||||
onCloseActiveReplication,
|
||||
processor,
|
||||
processSynchroniseResult,
|
||||
@@ -83,6 +120,86 @@ function setup(options: SetupOptions = {}) {
|
||||
}
|
||||
|
||||
describe("ReplicateResultProcessor", () => {
|
||||
it("does not add a permanent application block when snapshot recovery fails", async () => {
|
||||
const { processor } = setup({
|
||||
getSnapshot: async () => {
|
||||
throw new Error("KV unavailable");
|
||||
},
|
||||
});
|
||||
await expect(processor.restoreFromSnapshotOnce()).rejects.toThrow("KV unavailable");
|
||||
expect(processor.isSuspended).toBe(false);
|
||||
});
|
||||
|
||||
it("restores pending notes without retaining a past feature rejection in KV", async () => {
|
||||
const { processor, processSynchroniseResult, onCloseActiveReplication } = setup({
|
||||
databaseId: "same-database",
|
||||
getSnapshot: async () => ({
|
||||
databaseId: "same-database",
|
||||
invalidControlObserved: true,
|
||||
observedFeatures: ["future-format-v7"],
|
||||
observedGeneration: 14,
|
||||
queued: [note("recovered-note")],
|
||||
processing: [],
|
||||
}),
|
||||
});
|
||||
await processor.restoreFromSnapshotOnce();
|
||||
expect(processor.isSuspended).toBe(false);
|
||||
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
|
||||
expect(onCloseActiveReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Windows", isValidFilenameInWidows],
|
||||
["Android", isValidFilenameInAndroid],
|
||||
])("does not reflect a replicated colon path into the %s Vault", async (_platform, validatePath) => {
|
||||
const path = "Folder/Poem: Example.md" as FilePathWithPrefix;
|
||||
const document = { ...note("colon-path"), path };
|
||||
const { getDBEntryFromMeta, isValidPath, processor, processSynchroniseResult } = setup({
|
||||
isValidPath: validatePath,
|
||||
});
|
||||
|
||||
processor.enqueueAll([document]);
|
||||
|
||||
await vi.waitFor(() => expect(isValidPath).toHaveBeenCalledWith(path));
|
||||
await vi.waitFor(() => expect(processor["_processingChanges"]).toHaveLength(0));
|
||||
expect(getDBEntryFromMeta).toHaveBeenCalledWith(expect.objectContaining({ path }), false, true);
|
||||
expect(processSynchroniseResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resumes another document after in-flight updates to one document fill the application slots", async () => {
|
||||
const hotGate = promiseWithResolvers<boolean>();
|
||||
const { processor, processSynchroniseResult } = setup({
|
||||
processSynchroniseResult: async (entry) => {
|
||||
if ((entry as { _id: string })._id === "hot-queue") return await hotGate.promise;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
try {
|
||||
for (let index = 1; index <= 10; index++) {
|
||||
// A queued duplicate is coalesced; a new notification for a document
|
||||
// already being processed can occupy another application slot.
|
||||
processor.enqueueAll([note("hot-queue")]);
|
||||
await vi.waitFor(() => expect(processor["_processingChanges"]).toHaveLength(index));
|
||||
}
|
||||
processor.enqueueAll([note("unrelated-queue")]);
|
||||
await vi.waitFor(() => {
|
||||
expect(processor["_semaphore"].waiting).toBeGreaterThan(0);
|
||||
expect(processSynchroniseResult).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(processor["_queuedChanges"].map((entry) => entry._id)).toEqual(["unrelated-queue"]);
|
||||
} finally {
|
||||
hotGate.resolve(true);
|
||||
await vi.waitFor(() => {
|
||||
expect(processor["_processingChanges"]).toHaveLength(0);
|
||||
expect(processor["_queuedChanges"]).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
expect(processSynchroniseResult).toHaveBeenCalledTimes(11);
|
||||
expect(
|
||||
processSynchroniseResult.mock.calls.some(([entry]) => (entry as { _id: string })._id === "unrelated-queue")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("suspends result application while the application is not ready", () => {
|
||||
const { isReady, processor } = setup({ applicationReady: false });
|
||||
|
||||
@@ -90,13 +207,52 @@ describe("ReplicateResultProcessor", () => {
|
||||
expect(isReady).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("applies results in remediation mode, which never reports readiness", () => {
|
||||
const { processor } = setup({
|
||||
applicationReady: false,
|
||||
maxMTimeForReflectEvents: Date.parse("2026-09-01T00:00:00Z"),
|
||||
});
|
||||
|
||||
expect(processor.isSuspended).toBe(false);
|
||||
});
|
||||
|
||||
it("holds results in remediation mode while the local database is being rebuilt", () => {
|
||||
const { processor } = setup({
|
||||
applicationReady: false,
|
||||
databaseReady: false,
|
||||
maxMTimeForReflectEvents: Date.parse("2026-09-01T00:00:00Z"),
|
||||
});
|
||||
|
||||
expect(processor.isSuspended).toBe(true);
|
||||
});
|
||||
|
||||
it("still skips a document modified after the limit while the application is unready", async () => {
|
||||
const maxMTimeForReflectEvents = Date.parse("2026-09-01T00:00:00Z");
|
||||
const { processor, processSynchroniseResult } = setup({
|
||||
applicationReady: false,
|
||||
maxMTimeForReflectEvents,
|
||||
});
|
||||
|
||||
const tooRecent = {
|
||||
...note("too-recent"),
|
||||
mtime: maxMTimeForReflectEvents + 1,
|
||||
} as PouchDB.Core.ExistingDocument<EntryDoc>;
|
||||
processor.enqueueAll([tooRecent]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(processor["_queuedChanges"]).toHaveLength(0);
|
||||
expect(processor["_processingChanges"]).toHaveLength(0);
|
||||
});
|
||||
expect(processSynchroniseResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retires active ownership when a newer remote version is observed", async () => {
|
||||
const { onCloseActiveReplication, processor } = setup();
|
||||
const versionInfo = {
|
||||
_id: "versioninfo",
|
||||
_id: VERSIONING_DOCID,
|
||||
_rev: "1-test",
|
||||
type: "versioninfo",
|
||||
version: VER + 1,
|
||||
version: REMOTE_FEATURE_GENERATION + 1,
|
||||
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
|
||||
|
||||
processor.enqueueAll([versionInfo]);
|
||||
@@ -104,6 +260,64 @@ describe("ReplicateResultProcessor", () => {
|
||||
await vi.waitFor(() => expect(onCloseActiveReplication).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("continues applying documents after restoring a legacy local version document", async () => {
|
||||
const { onCloseActiveReplication, processor, processSynchroniseResult } = setup({
|
||||
localVersionInfo: {
|
||||
_id: VERSIONING_DOCID,
|
||||
type: "versioninfo",
|
||||
version: 11,
|
||||
},
|
||||
});
|
||||
|
||||
await processor.restoreFromSnapshotOnce();
|
||||
processor.enqueueAll([note("legacy-database-note")]);
|
||||
|
||||
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
|
||||
expect(processor.isSuspended).toBe(false);
|
||||
expect(onCloseActiveReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues when a newly received feature is supported", async () => {
|
||||
const { onCloseActiveReplication, processor, processSynchroniseResult } = setup();
|
||||
const versionInfo = {
|
||||
_id: VERSIONING_DOCID,
|
||||
_rev: "2-supported",
|
||||
type: "versioninfo",
|
||||
version: REMOTE_FEATURE_GENERATION,
|
||||
used_features: [ENCRYPTED_INTERNAL_METADATA_FEATURE],
|
||||
} as PouchDB.Core.ExistingDocument<EntryDoc>;
|
||||
|
||||
processor.enqueueAll([versionInfo, note("supported-update")]);
|
||||
|
||||
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
|
||||
expect(onCloseActiveReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports unknown feature identifiers and requests Replicator retirement", () => {
|
||||
const logger = vi.fn();
|
||||
setGlobalLogFunction(logger);
|
||||
try {
|
||||
const { processor, onCloseActiveReplication } = setup();
|
||||
processor.enqueueAll([
|
||||
{
|
||||
_id: VERSIONING_DOCID,
|
||||
_rev: "1-unknown",
|
||||
type: "versioninfo",
|
||||
version: REMOTE_FEATURE_GENERATION,
|
||||
used_features: ["future-format-v7"],
|
||||
} as PouchDB.Core.ExistingDocument<EntryDoc>,
|
||||
]);
|
||||
expect(onCloseActiveReplication).toHaveBeenCalledOnce();
|
||||
expect(logger).toHaveBeenCalledWith(
|
||||
expect.stringContaining("future-format-v7"),
|
||||
LOG_LEVEL_NOTICE,
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
setGlobalLogFunction(defaultLogger);
|
||||
}
|
||||
});
|
||||
|
||||
it("scans normal-file metadata without loading chunk documents and requeues it", async () => {
|
||||
const documents = [
|
||||
{ _id: "first", _rev: "1-a", type: "plain", path: "first.md" },
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
VERSIONING_DOCID,
|
||||
type EntryDoc,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import { skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
@@ -7,12 +11,27 @@ import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/rep
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
assessRemoteFeatureDocument,
|
||||
describeRemoteFeatureRejection,
|
||||
type ReplicatorInstance,
|
||||
type ReplicationFailureRequest,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { usesLegacyIndexedDBAdapter } from "@/common/compatibilitySettings";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import type PouchDB from "pouchdb-core";
|
||||
|
||||
async function canInterpretCleanupDatabase(db: PouchDB.Database<EntryDoc>): Promise<boolean> {
|
||||
try {
|
||||
const assessment = assessRemoteFeatureDocument(await db.get(VERSIONING_DOCID));
|
||||
if (assessment.status === "supported" || assessment.status === "older-generation") return true;
|
||||
Logger(`Database cleanup cancelled: ${describeRemoteFeatureRejection(assessment)}`, LOG_LEVEL_NOTICE);
|
||||
} catch (error) {
|
||||
Logger("Database cleanup cancelled: feature compatibility could not be checked.", LOG_LEVEL_NOTICE);
|
||||
Logger(error, LOG_LEVEL_INFO);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type CentralCompatibilityRecoveryServices = Pick<
|
||||
LiveSyncBaseCore["services"],
|
||||
@@ -60,6 +79,7 @@ export function createCentralCompatibilityRecovery(context: CentralCompatibility
|
||||
) {
|
||||
Logger("The remote database has been cleaned.", showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
await skipIfDuplicated("cleanup", async () => {
|
||||
if (!(await canInterpretCleanupDatabase(context.getLocalDatabase().localDatabase))) return;
|
||||
const count = await purgeUnreferencedChunks(context.getLocalDatabase().localDatabase, true);
|
||||
const message = `The remote database has been cleaned up.
|
||||
To synchronize, this device must be also cleaned up. ${count} chunk(s) will be erased from this device.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { VERSIONING_DOCID, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { defaultLogger, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger";
|
||||
import {
|
||||
@@ -24,6 +24,49 @@ import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/rep
|
||||
import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery";
|
||||
|
||||
describe("central compatibility recovery", () => {
|
||||
it("does not count chunks for cleanup when local feature requirements are unknown", async () => {
|
||||
chunkMocks.purgeUnreferencedChunks.mockClear();
|
||||
const confirmWithMessage = vi.fn(async () => "Dismiss");
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
confirm: { confirmWithMessage },
|
||||
getLocalDatabase: () => ({
|
||||
localDatabase: {
|
||||
get: vi.fn(async (id: string) => ({
|
||||
_id: id,
|
||||
type: "versioninfo",
|
||||
version: 13,
|
||||
used_features: ["future-format-v7"],
|
||||
})),
|
||||
},
|
||||
}),
|
||||
services: { replicator: {} },
|
||||
} as never);
|
||||
|
||||
await recovery.reconcileCleanedRemote(true, {} as ObsidianLiveSyncSettings, {} as never);
|
||||
|
||||
expect(chunkMocks.purgeUnreferencedChunks).not.toHaveBeenCalled();
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows cleanup counting for a legacy local version document", async () => {
|
||||
chunkMocks.purgeUnreferencedChunks.mockClear();
|
||||
const confirmWithMessage = vi.fn(async () => "Dismiss");
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
confirm: { confirmWithMessage },
|
||||
getLocalDatabase: () => ({
|
||||
localDatabase: {
|
||||
get: vi.fn(async (id: string) => ({ _id: id, type: "versioninfo", version: 11 })),
|
||||
},
|
||||
}),
|
||||
services: { replicator: {} },
|
||||
} as never);
|
||||
|
||||
await recovery.reconcileCleanedRemote(true, {} as ObsidianLiveSyncSettings, {} as never);
|
||||
|
||||
expect(chunkMocks.purgeUnreferencedChunks).toHaveBeenCalledWith(expect.anything(), true);
|
||||
expect(confirmWithMessage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("passes the failed attempt's exact tweak assessment to mismatch resolution", async () => {
|
||||
const setting = { customChunkSize: 0 };
|
||||
const preferredTweakValue = { customChunkSize: 60 };
|
||||
@@ -292,7 +335,9 @@ describe("central compatibility recovery", () => {
|
||||
});
|
||||
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
||||
const openOneShotReplication = vi.fn(async () => true);
|
||||
const remoteDatabase = { close: vi.fn(async () => undefined) };
|
||||
const remoteDatabase = {
|
||||
close: vi.fn(async () => undefined),
|
||||
};
|
||||
const close = vi.fn(async () => undefined);
|
||||
const activeReplicator = Object.assign(new LiveSyncCouchDBReplicator({} as never), {
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase, close })),
|
||||
@@ -303,7 +348,12 @@ describe("central compatibility recovery", () => {
|
||||
const runWithActiveReplicatorContext = vi.fn(async (task: (context: unknown) => unknown) =>
|
||||
task(expectedContext)
|
||||
);
|
||||
const localDatabase = { localDatabase: {}, clearCaches: vi.fn() };
|
||||
const localDatabase = {
|
||||
localDatabase: {
|
||||
get: vi.fn(async () => ({ _id: VERSIONING_DOCID, type: "versioninfo", version: 12 })),
|
||||
},
|
||||
clearCaches: vi.fn(),
|
||||
};
|
||||
const getLocalDatabase = vi.fn(() => localDatabase);
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") },
|
||||
@@ -335,7 +385,7 @@ describe("central compatibility recovery", () => {
|
||||
activityFinished.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce();
|
||||
expect(getLocalDatabase).toHaveBeenCalledTimes(2);
|
||||
expect(getLocalDatabase).toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(close.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ export function useReplicationFeature<TContext extends ServiceContext, TCommands
|
||||
: await task(),
|
||||
services: {
|
||||
appLifecycle: services.appLifecycle,
|
||||
database: services.database,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
vault: services.vault,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { VER, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { VERSIONING_DOCID, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { useReplicationFeature } from "./index";
|
||||
|
||||
@@ -19,8 +20,14 @@ type SetupOptions = {
|
||||
};
|
||||
|
||||
function setup(options: SetupOptions = {}) {
|
||||
const defaultLocalDatabase = {
|
||||
localDatabase: {},
|
||||
getRaw: vi.fn(async () => {
|
||||
throw { status: 404 };
|
||||
}),
|
||||
};
|
||||
const {
|
||||
getLocalDatabase = () => ({}),
|
||||
getLocalDatabase = () => defaultLocalDatabase,
|
||||
keyValueDB = {
|
||||
kvDB: {
|
||||
get: vi.fn(async () => undefined),
|
||||
@@ -39,7 +46,7 @@ function setup(options: SetupOptions = {}) {
|
||||
API: { isMobile: vi.fn(() => false), isOnline: true },
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: { addHandler: vi.fn() },
|
||||
isReady: true,
|
||||
isReady: vi.fn(() => true),
|
||||
isSuspended: vi.fn(() => false),
|
||||
onSettingLoaded: { addHandler: vi.fn() },
|
||||
},
|
||||
@@ -48,6 +55,7 @@ function setup(options: SetupOptions = {}) {
|
||||
keyValueDB,
|
||||
path: { getPath: vi.fn((entry: { path: string }) => entry.path) },
|
||||
replication: {
|
||||
replicationResultCount: { value: 0 },
|
||||
onBeforeReplicate: {
|
||||
addHandler: vi.fn((handler: BooleanHandler, priority = 0) => {
|
||||
beforeReplicateHandlers.set(priority, handler);
|
||||
@@ -165,10 +173,10 @@ describe("replication serviceFeature composition", () => {
|
||||
const onCloseActiveReplication = vi.fn(() => retirement.promise);
|
||||
const harness = setup({ onCloseActiveReplication });
|
||||
const versionInfo = {
|
||||
_id: "versioninfo",
|
||||
_id: VERSIONING_DOCID,
|
||||
_rev: "1-test",
|
||||
type: "versioninfo",
|
||||
version: VER + 1,
|
||||
version: REMOTE_FEATURE_GENERATION + 1,
|
||||
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
|
||||
|
||||
expect(harness.parseHandler).toBeDefined();
|
||||
|
||||
@@ -9,7 +9,8 @@ import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { SetupFeatureHost } from "./types";
|
||||
|
||||
export async function encodeSetupSettingsAsQR(host: SetupFeatureHost) {
|
||||
const settingString = encodeSettingsToQRCodeData(host.services.setting.currentSettings());
|
||||
const settings = host.services.setting.currentSettings();
|
||||
const settingString = encodeSettingsToQRCodeData(settings);
|
||||
const result = encodeQR(settingString, OutputFormat.SVG);
|
||||
if (result === "") {
|
||||
return "";
|
||||
|
||||
@@ -3,6 +3,9 @@ import { EVENT_REQUEST_SHOW_SETUP_QR } from "@vrtmrz/livesync-commonlib/compat/e
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { encodeSetupSettingsAsQR, useSetupQRCodeFeature } from "./qrCode";
|
||||
import { encodeQR, encodeSettingsToQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { copySetupURI } from "./setupUri";
|
||||
|
||||
vi.mock("./setupUri", () => ({ copySetupURI: vi.fn() }));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => {
|
||||
return {
|
||||
@@ -15,6 +18,32 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => {
|
||||
});
|
||||
|
||||
describe("setupObsidian/qrCode", () => {
|
||||
it("shows managed TURN settings and inactive profiles through the ordinary QR dialogue", async () => {
|
||||
const settings = {
|
||||
remoteConfigurations: {
|
||||
managed: { uri: "sls+p2p://room?managedType=CF&managedId=turn-key&token=private-token" },
|
||||
},
|
||||
};
|
||||
const confirmWithMessage = vi.fn();
|
||||
const translate = vi.fn(() => "qr-message");
|
||||
const host = {
|
||||
services: {
|
||||
API: { addLog: vi.fn() },
|
||||
context: createServiceContext({ translate }),
|
||||
setting: { currentSettings: () => settings },
|
||||
UI: { confirm: { confirmWithMessage } },
|
||||
},
|
||||
} as any;
|
||||
vi.mocked(encodeSettingsToQRCodeData).mockReturnValue("encoded-settings");
|
||||
vi.mocked(encodeQR).mockReturnValue("<svg/>");
|
||||
|
||||
expect(await encodeSetupSettingsAsQR(host)).toBe("<svg/>");
|
||||
expect(encodeSettingsToQRCodeData).toHaveBeenCalledWith(settings);
|
||||
expect(translate).toHaveBeenCalledWith("Setup.QRCode", { qr_image: "<svg/>" });
|
||||
expect(confirmWithMessage).toHaveBeenCalledWith("Settings QR Code", "qr-message", ["OK"], "OK");
|
||||
expect(copySetupURI).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { acquireCloudflareTurnCredentials, type CloudflareTurnFetch } from "@/integrations/cloudflare/turnCredentials";
|
||||
import { validateManagedTurnSettings } from "@/integrations/turnSettings";
|
||||
|
||||
/** Prepare a connection copy using the host's HTTP adapter. */
|
||||
export function useP2PSettingsPreparation(fetch: CloudflareTurnFetch) {
|
||||
return async (settings: Readonly<P2PSyncSetting>, signal: AbortSignal): Promise<P2PSyncSetting> => {
|
||||
const error = validateManagedTurnSettings(settings);
|
||||
if (error) throw new Error(error);
|
||||
if (!settings.P2P_managedType) return { ...settings };
|
||||
const { iceServers, expiresAt } = await acquireCloudflareTurnCredentials(
|
||||
{ turnKeyId: settings.P2P_managedId ?? "", apiToken: settings.P2P_managedToken ?? "" },
|
||||
{ fetch },
|
||||
signal
|
||||
);
|
||||
return { ...settings, P2P_iceServers: iceServers, P2P_iceServersExpiresAt: expiresAt };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { useP2PSettingsPreparation } from "./useP2PSettingsPreparation";
|
||||
|
||||
const managed = {
|
||||
...DEFAULT_SETTINGS,
|
||||
P2P_managedType: "CF",
|
||||
P2P_managedId: "key-123",
|
||||
P2P_managedToken: "test-token",
|
||||
};
|
||||
|
||||
describe("host preparation of P2P settings", () => {
|
||||
it("puts issued ICE credentials on a connection copy without changing saved inputs", async () => {
|
||||
const iceServers = [
|
||||
{ urls: ["turn:relay.example.test:3478"], username: "issued-user", credential: "issued-password" },
|
||||
];
|
||||
const fetch = vi.fn(async () => new Response(JSON.stringify({ iceServers }), { status: 201 }));
|
||||
const before = structuredClone(managed);
|
||||
const settings = await useP2PSettingsPreparation(fetch)(managed, new AbortController().signal);
|
||||
expect(settings.P2P_iceServers).toEqual(iceServers);
|
||||
expect(settings.P2P_iceServersExpiresAt).toBeGreaterThan(Date.now());
|
||||
expect(managed).toEqual(before);
|
||||
expect(settings).not.toBe(managed);
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps manual settings and rejects an unsupported provider without HTTP requests", async () => {
|
||||
const fetch = vi.fn();
|
||||
const prepare = useP2PSettingsPreparation(fetch);
|
||||
await expect(prepare(DEFAULT_SETTINGS, new AbortController().signal)).resolves.toEqual(DEFAULT_SETTINGS);
|
||||
await expect(prepare({ ...managed, P2P_managedType: "unknown" }, new AbortController().signal)).rejects.toThrow(
|
||||
"not supported"
|
||||
);
|
||||
await expect(
|
||||
prepare({ ...managed, P2P_managedToken: "invalid token" }, new AbortController().signal)
|
||||
).rejects.toThrow("Bearer token syntax");
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates a safe acquisition failure without using the manual TURN fields", async () => {
|
||||
const fetch = vi.fn(async () => new Response(null, { status: 401 }));
|
||||
const prepare = useP2PSettingsPreparation(fetch);
|
||||
await expect(
|
||||
prepare({ ...managed, P2P_turnServers: "turn:manual.example.test" }, new AbortController().signal)
|
||||
).rejects.toThrow("not authorised");
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
runReviewHarnessVaultRoundTrip,
|
||||
} from "@/features/ReviewHarness/reviewHarnessVaultFixture";
|
||||
import type { CompatibilityReviewController } from "./compatibilityReview";
|
||||
import { runReviewHarnessIdBenchmark } from "@/features/ReviewHarness/reviewHarnessIdBenchmark";
|
||||
import { createIdBenchmarkOperations } from "@/features/ReviewHarness/reviewHarnessIdBenchmarkRuntime";
|
||||
|
||||
async function runVaultRoundTrip(plugin: ObsidianLiveSyncPlugin): Promise<ReviewHarnessScenarioResult> {
|
||||
const vault = plugin.app.vault;
|
||||
@@ -58,6 +60,8 @@ export function useReviewHarness(
|
||||
getCompatibilityPause: () => compatibilityReview.pendingPause,
|
||||
openCompatibilityReview: () => compatibilityReview.openReview(),
|
||||
runVaultRoundTrip: () => runVaultRoundTrip(plugin),
|
||||
runIdBenchmark: async () =>
|
||||
runReviewHarnessIdBenchmark(await createIdBenchmarkOperations(), activeWindow.performance),
|
||||
readContinuation: () => services.setting.getSmallConfig(REVIEW_HARNESS_STATE_KEY),
|
||||
writeContinuation: (value) => services.setting.setSmallConfig(REVIEW_HARNESS_STATE_KEY, value),
|
||||
deleteContinuation: () => services.setting.deleteSmallConfig(REVIEW_HARNESS_STATE_KEY),
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import PouchDB from "pouchdb-core";
|
||||
import MemoryAdapter from "pouchdb-adapter-memory";
|
||||
import HttpAdapter from "pouchdb-adapter-http";
|
||||
import replication from "pouchdb-replication";
|
||||
import type { EntryDoc, FilePathWithPrefix, UXFileInfo } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { compareMTime, createTextBlob, readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { createLiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { LiveSyncLocalDB, type LiveSyncLocalDBEnv } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
|
||||
import {
|
||||
ServiceDatabaseFileAccessBase,
|
||||
type ServiceDatabaseFileAccessDependencies,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceModules/ServiceDatabaseFileAccessBase";
|
||||
import type { ServiceFileHandlerDependencies } from "@vrtmrz/livesync-commonlib/compat/serviceModules/ServiceFileHandlerBase";
|
||||
import { ServiceFileHandler } from "./FileHandler";
|
||||
import {
|
||||
createConflictResolutionOperations,
|
||||
type ConflictResolutionOperationsDependencies,
|
||||
} from "@/serviceFeatures/conflictResolution/operations";
|
||||
import { runCommand } from "@/apps/cli/commands/runCommand";
|
||||
import type { CLICommandContext } from "@/apps/cli/commands/types";
|
||||
|
||||
PouchDB.plugin(MemoryAdapter).plugin(HttpAdapter).plugin(replication);
|
||||
const path = "multi-device.txt" as FilePathWithPrefix;
|
||||
const old = "Original content\n";
|
||||
const oldTime = 1_000_000;
|
||||
class TestHandler extends ServiceFileHandler {}
|
||||
|
||||
function makeFile(body: string, mtime = oldTime): UXFileInfo {
|
||||
return {
|
||||
name: path,
|
||||
path,
|
||||
stat: { type: "file", ctime: oldTime, mtime, size: new Blob([body]).size },
|
||||
body: createTextBlob(body),
|
||||
};
|
||||
}
|
||||
|
||||
async function makeDevice(name: string) {
|
||||
const db = new PouchDB<EntryDoc>(name, { adapter: "memory" });
|
||||
const reflection = new Map<FilePathWithPrefix, { revision: string; observedStorageMtime?: number }>();
|
||||
let storage = makeFile(old);
|
||||
const settings = { ...DEFAULT_SETTINGS, useOnlyLocalChunk: true, writeDocumentsIfConflicted: false };
|
||||
const setting = { currentSettings: () => settings };
|
||||
const pathService = {
|
||||
path2id: (value: string) => Promise.resolve(value),
|
||||
id2path: (id: string, entry?: { path?: string }) => entry?.path ?? id,
|
||||
getPath: (entry: { path: FilePathWithPrefix }) => entry.path,
|
||||
compareFileFreshness: (file: UXFileInfo, entry: { mtime: number }) =>
|
||||
compareMTime(file.stat.mtime, entry.mtime),
|
||||
markChangesAreSame: vi.fn(),
|
||||
};
|
||||
const events = createLiveSyncEventHub();
|
||||
const API = { addLog: vi.fn() };
|
||||
const localDatabase = new LiveSyncLocalDB(name, {
|
||||
services: {
|
||||
API,
|
||||
setting,
|
||||
path: pathService,
|
||||
context: { events },
|
||||
database: { createPouchDBInstance: () => db },
|
||||
databaseEvents: {
|
||||
onDatabaseInitialisation: () => Promise.resolve(true),
|
||||
onDatabaseHasReady: () => Promise.resolve(true),
|
||||
onCloseDatabase: () => Promise.resolve(true),
|
||||
onUnloadDatabase: () => Promise.resolve(true),
|
||||
},
|
||||
replicator: { onCloseActiveReplication: () => Promise.resolve(true) },
|
||||
},
|
||||
} as unknown as LiveSyncLocalDBEnv);
|
||||
await expect(localDatabase.initializeDatabase()).resolves.toBe(true);
|
||||
const storageAccess = {
|
||||
getStub: () => Promise.resolve(storage),
|
||||
getFileStub: () => Promise.resolve(storage),
|
||||
readStubContent: () => Promise.resolve(storage),
|
||||
ensureDir: () => Promise.resolve(true),
|
||||
writeFileAuto: vi.fn((_path: string, body: string, times: { mtime: number }) => {
|
||||
storage = makeFile(body, times.mtime);
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
stat: () => Promise.resolve(storage.stat),
|
||||
touched: () => Promise.resolve(),
|
||||
triggerFileEvent: vi.fn(),
|
||||
};
|
||||
const conflict = { queueCheckFor: vi.fn(), queueCheckForIfOpen: vi.fn() };
|
||||
const services = {
|
||||
API,
|
||||
path: pathService,
|
||||
setting,
|
||||
events,
|
||||
database: { localDatabase },
|
||||
vault: { isTargetFile: () => Promise.resolve(true), isFileSizeTooLarge: () => false },
|
||||
storageAccess,
|
||||
conflict,
|
||||
fileReflectionProvenance: {
|
||||
get: (value: FilePathWithPrefix) => Promise.resolve(reflection.get(value)),
|
||||
set: (value: FilePathWithPrefix, record: { revision: string }) => {
|
||||
reflection.set(value, record);
|
||||
return Promise.resolve();
|
||||
},
|
||||
delete: (value: FilePathWithPrefix) => {
|
||||
reflection.delete(value);
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
fileProcessing: { processFileEvent: { addHandler: vi.fn() } },
|
||||
replication: { processSynchroniseResult: { addHandler: vi.fn() } },
|
||||
} as unknown as ServiceFileHandlerDependencies & ServiceDatabaseFileAccessDependencies;
|
||||
const access = new ServiceDatabaseFileAccessBase(services);
|
||||
(services as ServiceFileHandlerDependencies).databaseFileAccess = access;
|
||||
const handler = new TestHandler(services);
|
||||
return {
|
||||
db,
|
||||
localDatabase,
|
||||
access,
|
||||
handler,
|
||||
conflict,
|
||||
reflection,
|
||||
storageAccess,
|
||||
settings,
|
||||
services,
|
||||
getStorage: () => storage,
|
||||
setStorage: (file: UXFileInfo) => {
|
||||
storage = file;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type Device = Awaited<ReturnType<typeof makeDevice>>;
|
||||
|
||||
function requiredEnvironment(name: "hostname" | "username" | "password"): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Missing integration-test environment variable: ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Read every non-deleted leaf, including branches which are not the winner. */
|
||||
async function leaves(db: PouchDB.Database<EntryDoc>) {
|
||||
const docs = await db.get(path, { open_revs: "all", revs: true });
|
||||
return docs
|
||||
.flatMap((result) => ("ok" in result && !result.ok._deleted ? [result.ok] : []))
|
||||
.sort((left, right) => left._rev.localeCompare(right._rev));
|
||||
}
|
||||
|
||||
async function revisionContent(device: Device, rev: string) {
|
||||
const entry = await device.access.fetchEntry(path, rev, true);
|
||||
if (!entry) throw new Error(`Missing content for ${rev}`);
|
||||
return readContent(entry);
|
||||
}
|
||||
|
||||
/** Exercise the real CLI dispatcher and conflict operations with the fixture's real DB services. */
|
||||
async function resolveFromCLI(device: Device, keep: string) {
|
||||
const operations = createConflictResolutionOperations({
|
||||
events: device.services.events,
|
||||
databaseFileAccess: device.access,
|
||||
fileHandler: device.handler,
|
||||
log: vi.fn(),
|
||||
} as unknown as ConflictResolutionOperationsDependencies);
|
||||
const context = {
|
||||
databasePath: "/fixture",
|
||||
vaultPath: "/fixture",
|
||||
core: {
|
||||
services: {
|
||||
context: { standardIo: { writeStdout: vi.fn(), writeStderr: vi.fn() } },
|
||||
control: { activated: Promise.resolve() },
|
||||
conflict: { resolveByDeletingRevision: operations.resolveByDeletingRevision },
|
||||
},
|
||||
serviceModules: { databaseFileAccess: device.access, fileHandler: device.handler },
|
||||
},
|
||||
} as unknown as CLICommandContext;
|
||||
await expect(runCommand({ command: "resolve", commandArgs: [path, keep] }, context)).resolves.toBe(true);
|
||||
}
|
||||
|
||||
describe("file provenance across multiple devices and real CouchDB", () => {
|
||||
const databases: PouchDB.Database<EntryDoc>[] = [];
|
||||
const owners: LiveSyncLocalDB[] = [];
|
||||
afterEach(async () => {
|
||||
for (const owner of owners.splice(0)) {
|
||||
owner.offRemoteChunkFetchedHandler?.();
|
||||
await owner.managers.teardownManagers();
|
||||
}
|
||||
const results = await Promise.allSettled(databases.splice(0).map((db) => db.destroy()));
|
||||
for (const result of results) if (result.status === "rejected") throw result.reason;
|
||||
});
|
||||
|
||||
/** Replication deliberately precedes file reflection, modelling a delayed storage event. */
|
||||
async function conflictedDevices(count: number) {
|
||||
const name = `livesync-provenance-${crypto.randomUUID()}`;
|
||||
const remote = new PouchDB<EntryDoc>(`${requiredEnvironment("hostname").replace(/\/+$/u, "")}/${name}`, {
|
||||
adapter: "http",
|
||||
auth: { username: requiredEnvironment("username"), password: requiredEnvironment("password") },
|
||||
});
|
||||
databases.push(remote);
|
||||
await remote.info();
|
||||
const devices: Device[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const device = await makeDevice(`${name}-${i}`);
|
||||
devices.push(device);
|
||||
owners.push(device.localDatabase);
|
||||
databases.push(device.db);
|
||||
}
|
||||
const root = await devices[0].access.storeWithBaseRevision(makeFile(old), undefined, true);
|
||||
if (!root) throw new Error("Could not create the shared original revision");
|
||||
await devices[0].db.replicate.to(remote);
|
||||
const revisions: string[] = [];
|
||||
for (const [index, device] of devices.entries()) {
|
||||
await device.db.replicate.from(remote);
|
||||
device.reflection.set(path, { revision: root });
|
||||
// All devices edit while disconnected. Equal mtimes rule out timestamp-based detection.
|
||||
device.setStorage(makeFile(`Edited on device ${index}\n`));
|
||||
await expect(device.handler.storeFileToDB(path)).resolves.toBe(true);
|
||||
revisions.push((await device.db.get(path))._rev);
|
||||
}
|
||||
// Reverse upload order so the fixture does not rely on the first writer winning.
|
||||
for (const device of [...devices].reverse()) await device.db.replicate.to(remote);
|
||||
for (const device of devices) {
|
||||
await device.db.replicate.from(remote);
|
||||
expect((await leaves(device.db)).map((doc) => doc._rev)).toEqual([...revisions].sort());
|
||||
expect(await Promise.all(revisions.map((rev) => revisionContent(device, rev)))).toEqual(
|
||||
devices.map((_, index) => `Edited on device ${index}\n`)
|
||||
);
|
||||
}
|
||||
expect(await leaves(remote)).toHaveLength(count);
|
||||
return { devices, remote, root, revisions };
|
||||
}
|
||||
|
||||
async function resolveAndReplicate(f: Awaited<ReturnType<typeof conflictedDevices>>) {
|
||||
const winner = (await f.devices[0].db.get(path))._rev;
|
||||
const keepIndex = f.revisions.findIndex((rev) => rev !== winner);
|
||||
const keep = f.revisions[keepIndex];
|
||||
const content = await revisionContent(f.devices[0], keep);
|
||||
await resolveFromCLI(f.devices[0], keep);
|
||||
expect((await leaves(f.devices[0].db)).map((doc) => doc._rev)).toEqual([keep]);
|
||||
expect(await f.devices[0].getStorage().body.text()).toBe(content);
|
||||
expect(f.devices[0].reflection.get(path)?.revision).toBe(keep);
|
||||
await f.devices[0].db.replicate.to(f.remote);
|
||||
for (const device of f.devices) await device.db.replicate.from(f.remote);
|
||||
return { keep, content };
|
||||
}
|
||||
|
||||
it.each([3, 4])(
|
||||
"does not resurrect unchanged files before or after CLI resolution with %i editing devices",
|
||||
async (count) => {
|
||||
const f = await conflictedDevices(count);
|
||||
for (const [index, device] of f.devices.entries()) {
|
||||
const before = (await device.db.info()).update_seq;
|
||||
await expect(device.handler.storeFileToDB(path)).resolves.toBe(true);
|
||||
expect((await device.db.info()).update_seq).toBe(before);
|
||||
expect(await device.getStorage().body.text()).toBe(`Edited on device ${index}\n`);
|
||||
expect(device.conflict.queueCheckFor).toHaveBeenCalled();
|
||||
}
|
||||
const { keep, content } = await resolveAndReplicate(f);
|
||||
for (const device of f.devices) {
|
||||
const before = (await device.db.info()).update_seq;
|
||||
await expect(device.handler.storeFileToDB(path)).resolves.toBe(true);
|
||||
expect((await device.db.info()).update_seq).toBe(before);
|
||||
expect(await device.getStorage().body.text()).toBe(content);
|
||||
expect(device.reflection.get(path)?.revision).toBe(keep);
|
||||
await device.db.replicate.to(f.remote);
|
||||
}
|
||||
for (const device of f.devices) {
|
||||
await device.db.replicate.from(f.remote);
|
||||
expect((await leaves(device.db)).map((doc) => doc._rev)).toEqual([keep]);
|
||||
}
|
||||
expect((await leaves(f.remote)).map((doc) => doc._rev)).toEqual([keep]);
|
||||
},
|
||||
60_000
|
||||
);
|
||||
|
||||
it("preserves a real edit made on a losing device after three-way resolution", async () => {
|
||||
const f = await conflictedDevices(3);
|
||||
const { keep, content } = await resolveAndReplicate(f);
|
||||
const index = f.revisions.findIndex((rev, i) => i > 0 && rev !== keep);
|
||||
const device = f.devices[index];
|
||||
const edit = `${await device.getStorage().body.text()}A further offline edit\n`;
|
||||
device.setStorage(makeFile(edit));
|
||||
await expect(device.handler.storeFileToDB(path)).resolves.toBe(true);
|
||||
const editedRevision = device.reflection.get(path)!.revision;
|
||||
const edited = await device.db.get(path, { rev: editedRevision, revs: true });
|
||||
expect(edited._revisions?.ids[1]).toBe(f.revisions[index].split("-")[1]);
|
||||
await device.db.replicate.to(f.remote);
|
||||
for (const peer of f.devices) {
|
||||
await peer.db.replicate.from(f.remote);
|
||||
expect((await leaves(peer.db)).map((doc) => doc._rev)).toEqual([keep, editedRevision].sort());
|
||||
expect(await revisionContent(peer, keep)).toBe(content);
|
||||
expect(await revisionContent(peer, editedRevision)).toBe(edit);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.each(["missing record", "compacted base"] as const)(
|
||||
"preserves uncertain storage as one independent conflict with four devices: %s",
|
||||
async (reason) => {
|
||||
const f = await conflictedDevices(4);
|
||||
const { keep, content } = await resolveAndReplicate(f);
|
||||
const index = f.revisions.findIndex((rev, i) => i > 0 && rev !== keep);
|
||||
const device = f.devices[index];
|
||||
if (reason === "missing record") {
|
||||
device.reflection.delete(path);
|
||||
// Matching an old ancestor must not be mistaken for an unchanged current branch.
|
||||
device.setStorage(makeFile(old));
|
||||
} else {
|
||||
await device.db.compact();
|
||||
await expect(device.db.get(path, { rev: f.revisions[index] })).rejects.toMatchObject({ status: 404 });
|
||||
}
|
||||
const uncertainContent = await device.getStorage().body.text();
|
||||
await expect(device.handler.storeFileToDB(path)).resolves.toBe(true);
|
||||
const independent = device.reflection.get(path)!.revision;
|
||||
expect(independent).toMatch(/^1-/u);
|
||||
expect(independent).not.toBe(f.root);
|
||||
expect((await device.db.get(path, { rev: independent, revs: true }))._revisions?.ids).toHaveLength(1);
|
||||
const before = (await device.db.info()).update_seq;
|
||||
device.reflection.delete(path);
|
||||
await expect(device.handler.storeFileToDB(path)).resolves.toBe(true);
|
||||
await expect(device.handler.storeFileToDB(path)).resolves.toBe(true);
|
||||
expect((await device.db.info()).update_seq).toBe(before);
|
||||
await device.db.replicate.to(f.remote);
|
||||
for (const peer of f.devices) {
|
||||
await peer.db.replicate.from(f.remote);
|
||||
expect((await leaves(peer.db)).map((doc) => doc._rev)).toEqual([keep, independent].sort());
|
||||
expect(await revisionContent(peer, keep)).toBe(content);
|
||||
expect(await revisionContent(peer, independent)).toBe(uncertainContent);
|
||||
}
|
||||
// The CLI must also accept the independent root as the selected conflict.
|
||||
await resolveFromCLI(f.devices[0], independent);
|
||||
await f.devices[0].db.replicate.to(f.remote);
|
||||
for (const peer of f.devices) {
|
||||
await peer.db.replicate.from(f.remote);
|
||||
expect((await leaves(peer.db)).map((doc) => doc._rev)).toEqual([independent]);
|
||||
}
|
||||
expect(await f.devices[0].getStorage().body.text()).toBe(uncertainContent);
|
||||
},
|
||||
60_000
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user