mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-22 17:13:20 +00:00
274 lines
13 KiB
TypeScript
274 lines
13 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { DoctorRegulation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
|
|
import { evalObsidianJson } from "../runner/cli.ts";
|
|
import {
|
|
assertCouchDbReachable,
|
|
createCouchDbDatabase,
|
|
deleteCouchDbDatabase,
|
|
fetchAllCouchDbDocs,
|
|
fetchCouchDbDatabaseInfo,
|
|
fetchCouchDbDocument,
|
|
fetchCouchDbLocalDocs,
|
|
loadCouchDbConfig,
|
|
makeUniqueDatabaseName,
|
|
putCouchDbDocument,
|
|
type CouchDbConfig,
|
|
} from "../runner/couchdb.ts";
|
|
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
|
|
import {
|
|
createE2eCouchDbPluginData,
|
|
createE2eObsidianDeviceLocalState,
|
|
prepareRemote,
|
|
pushLocalChanges,
|
|
waitForLiveSyncCoreReady,
|
|
waitForLocalDatabaseEntry,
|
|
} from "../runner/liveSyncWorkflow.ts";
|
|
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
|
import { withObsidianPage } from "../runner/ui.ts";
|
|
import { createTemporaryVault } from "../runner/vault.ts";
|
|
|
|
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
|
const fixturePath = "Diagnostic/remote-秘密.md";
|
|
const fixtureContent = "Diagnostic remote fixture contents must stay outside reports.\n";
|
|
const passphrase = "diagnostic-remote-fixture-passphrase";
|
|
const timeout = 20000;
|
|
|
|
interface IntegrityReport {
|
|
status: string;
|
|
coverage: { local: { status: string }; remote: { status: string; contentReads: number } };
|
|
observations: {
|
|
source: string;
|
|
identity: string;
|
|
content: { status: string; chunksRead: number };
|
|
comparisons: { relation: string }[];
|
|
}[];
|
|
errors: unknown[];
|
|
}
|
|
|
|
async function localState(cli: string, session: ObsidianLiveSyncSession) {
|
|
const state = await evalObsidianJson<unknown>(
|
|
cli,
|
|
"(async()=>{const core=app.plugins.plugins['obsidian-livesync'].core;const files=await Promise.all(app.vault.getFiles().map(async file=>[file.path,await app.vault.read(file)]));files.sort((a,b)=>a[0].localeCompare(b[0]));return JSON.stringify({sequence:(await core.localDatabase.localDatabase.info()).update_seq,settings:core.services.setting.currentSettings(),files});})()",
|
|
session.cliEnv
|
|
);
|
|
return createHash("sha256").update(JSON.stringify(state)).digest("hex");
|
|
}
|
|
|
|
async function remoteState(config: CouchDbConfig, name: string) {
|
|
const [info, documents, localDocuments] = await Promise.all([
|
|
fetchCouchDbDatabaseInfo(config, name),
|
|
fetchAllCouchDbDocs(config, name),
|
|
fetchCouchDbLocalDocs(config, name),
|
|
]);
|
|
// Hash the snapshot so a failed assertion cannot print Security Seed data.
|
|
return createHash("sha256")
|
|
.update(JSON.stringify([info.update_seq, documents, localDocuments]))
|
|
.digest("hex");
|
|
}
|
|
|
|
async function observeRequests(cli: string, session: ObsidianLiveSyncSession) {
|
|
await evalObsidianJson(
|
|
cli,
|
|
`(() => {
|
|
const api=app.plugins.plugins['obsidian-livesync'].core.services.API;
|
|
const state={calls:[],restore:undefined};
|
|
const originals={};
|
|
for(const name of ['webCompatFetch','nativeFetch']) {
|
|
originals[name]=api[name];
|
|
api[name]=function(request,options) {
|
|
state.calls.push({transport:name,method:options?.method??(typeof request==='string'?'GET':request.method)});
|
|
return originals[name].call(this,request,options);
|
|
};
|
|
}
|
|
state.restore=()=>{for(const name of Object.keys(originals)) api[name]=originals[name];};
|
|
globalThis.diagnosticRequests=state;
|
|
return JSON.stringify(true);
|
|
})()`,
|
|
session.cliEnv
|
|
);
|
|
}
|
|
|
|
async function finishRequests(cli: string, session: ObsidianLiveSyncSession) {
|
|
return await evalObsidianJson<{ transport: string; method: string }[]>(
|
|
cli,
|
|
"(()=>{const state=globalThis.diagnosticRequests;if(!state)return JSON.stringify([]);state.restore();delete globalThis.diagnosticRequests;return JSON.stringify(state.calls);})()",
|
|
session.cliEnv
|
|
);
|
|
}
|
|
|
|
async function runCase(config: CouchDbConfig, encrypted: boolean) {
|
|
const binary = requireObsidianBinary();
|
|
const cli = requireObsidianCli();
|
|
const name = makeUniqueDatabaseName(config.dbPrefix, "diagnostic");
|
|
const vault = await createTemporaryVault("obsidian-livesync-remote-diagnostic-");
|
|
let session: ObsidianLiveSyncSession | undefined;
|
|
let created = false;
|
|
try {
|
|
await createCouchDbDatabase(config, name);
|
|
created = true;
|
|
session = await startObsidianLiveSyncSession({
|
|
binary,
|
|
cliBinary: cli,
|
|
vault,
|
|
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT?.trim() || undefined,
|
|
pluginData: createE2eCouchDbPluginData(
|
|
{ ...config, dbName: name },
|
|
{
|
|
doctorProcessedVersion: DoctorRegulation.version,
|
|
useAdvancedMode: true,
|
|
encrypt: encrypted,
|
|
passphrase: encrypted ? passphrase : "",
|
|
usePathObfuscation: encrypted,
|
|
encryptInternalMetadata: encrypted,
|
|
E2EEAlgorithm: "v2",
|
|
useRequestAPI: true,
|
|
periodicReplication: false,
|
|
syncAfterMerge: false,
|
|
syncOnEditorSave: false,
|
|
syncOnFileOpen: false,
|
|
notifyThresholdOfRemoteStorageSize: -1,
|
|
}
|
|
),
|
|
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
|
});
|
|
await waitForLiveSyncCoreReady(cli, session.cliEnv);
|
|
await prepareRemote(cli, session.cliEnv);
|
|
await evalObsidianJson(
|
|
cli,
|
|
`(async()=>{await app.vault.createFolder('Diagnostic');await app.vault.create(${JSON.stringify(fixturePath)},${JSON.stringify(fixtureContent)});return JSON.stringify(true);})()`,
|
|
session.cliEnv
|
|
);
|
|
const entry = await waitForLocalDatabaseEntry(cli, session.cliEnv, fixturePath);
|
|
await pushLocalChanges(cli, session.cliEnv);
|
|
const original = await fetchCouchDbDocument(config, name, entry.id);
|
|
assert.ok(entry.children.length > 0);
|
|
if (encrypted) assert.ok(!JSON.stringify(original).includes(fixturePath));
|
|
const shadowId = await evalObsidianJson<string>(
|
|
cli,
|
|
"(async()=>JSON.stringify(await app.plugins.plugins['obsidian-livesync'].core.services.path.path2id('Diagnostic/different-identity.md')))()",
|
|
session.cliEnv
|
|
);
|
|
const shadow = { ...original, _id: shadowId };
|
|
delete shadow._rev;
|
|
await putCouchDbDocument(config, name, shadow);
|
|
|
|
for (const native of [false, true]) {
|
|
await evalObsidianJson(
|
|
cli,
|
|
`(async()=>{await app.plugins.plugins['obsidian-livesync'].core.services.setting.applyPartial({useRequestAPI:${native}});return JSON.stringify(true);})()`,
|
|
session.cliEnv
|
|
);
|
|
const localBefore = await localState(cli, session);
|
|
const remoteBefore = await remoteState(config, name);
|
|
await observeRequests(cli, session);
|
|
let calls: { transport: string; method: string }[] = [];
|
|
try {
|
|
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
|
for (const names of ["pseudonym", "original"]) {
|
|
const opened = await page.evaluate(
|
|
"app.commands.executeCommandById('obsidian-livesync:inspect-database-integrity')"
|
|
);
|
|
assert.equal(opened, true);
|
|
const dialogue = page.locator(".database-diagnostic");
|
|
await dialogue.waitFor({ state: "visible", timeout });
|
|
const nameOptions = dialogue.getByRole("group", { name: "Names in this report", exact: true });
|
|
assert.equal(await nameOptions.getByRole("radio").count(), 2);
|
|
const nameLabel = names === "pseudonym" ? "Pseudonyms (recommended)" : "Original names";
|
|
await nameOptions.getByRole("radio", { name: nameLabel, exact: true }).check();
|
|
assert.equal(await nameOptions.getByRole("radio", { checked: true }).count(), 1);
|
|
await dialogue.getByRole("button", { name: "Begin inspection", exact: true }).click();
|
|
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
|
|
const formats = dialogue.getByRole("group", { name: "Report format", exact: true });
|
|
assert.equal(await formats.getByRole("radio").count(), 2);
|
|
for (const name of ["MARKDOWN", "JSON"])
|
|
assert.equal(await formats.getByRole("radio", { name, exact: true }).isVisible(), true);
|
|
await formats.getByRole("radio", { name: "JSON", exact: true }).check();
|
|
const text = await dialogue.locator("textarea").inputValue();
|
|
const report = JSON.parse(text) as IntegrityReport;
|
|
assert.equal(report.status, "complete");
|
|
assert.equal(report.coverage.local.status, "complete");
|
|
assert.equal(report.coverage.remote.status, "complete");
|
|
assert.ok(report.coverage.remote.contentReads > 0);
|
|
assert.deepEqual(report.errors, []);
|
|
const suspect = report.observations.find(
|
|
(row) => row.source === "remote" && row.identity === "document-id-mismatch"
|
|
);
|
|
assert.ok(suspect);
|
|
assert.equal(suspect.content.status, "available");
|
|
assert.ok(suspect.content.chunksRead > 0);
|
|
assert.ok(suspect.comparisons.some((comparison) => comparison.relation === "equal"));
|
|
await formats.getByRole("radio", { name: "MARKDOWN", exact: true }).check();
|
|
assert.equal(await formats.getByRole("radio", { checked: true }).count(), 1);
|
|
const markdown = await dialogue.locator("textarea").inputValue();
|
|
for (const reportText of [text, markdown]) {
|
|
for (const secret of [fixtureContent.trim(), passphrase, config.password, config.uri]) {
|
|
assert.ok(
|
|
!reportText.includes(secret),
|
|
"A report exposed fixture contents or credentials"
|
|
);
|
|
}
|
|
if (names === "pseudonym") {
|
|
for (const privateName of [fixturePath, entry.id, shadowId, ...entry.children]) {
|
|
assert.ok(
|
|
!reportText.includes(privateName),
|
|
"A pseudonym report exposed a raw identifier"
|
|
);
|
|
}
|
|
} else assert.ok(reportText.includes(fixturePath));
|
|
}
|
|
await dialogue.getByRole("button", { name: "Close", exact: true }).click();
|
|
await dialogue.waitFor({ state: "hidden", timeout });
|
|
}
|
|
});
|
|
} finally {
|
|
calls = await finishRequests(cli, session);
|
|
}
|
|
assert.ok(calls.some((call) => call.transport === (native ? "nativeFetch" : "webCompatFetch")));
|
|
assert.ok(
|
|
calls.every((call) => call.method === "GET"),
|
|
"Diagnostic requests must be read-only"
|
|
);
|
|
assert.equal(
|
|
await localState(cli, session),
|
|
localBefore,
|
|
"Diagnostics changed the local database, settings, or Vault"
|
|
);
|
|
assert.equal(
|
|
await remoteState(config, name),
|
|
remoteBefore,
|
|
"Diagnostics changed remote documents or Security Seed"
|
|
);
|
|
}
|
|
console.log(
|
|
JSON.stringify({
|
|
encrypted,
|
|
transports: ["webCompatFetch", "nativeFetch"],
|
|
names: ["pseudonym", "original"],
|
|
readOnly: true,
|
|
})
|
|
);
|
|
} finally {
|
|
try {
|
|
if (session) await session.app.stop();
|
|
} finally {
|
|
try {
|
|
await vault.dispose();
|
|
} finally {
|
|
if (created) await deleteCouchDbDatabase(config, name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const config = await loadCouchDbConfig();
|
|
await assertCouchDbReachable(config);
|
|
for (const encrypted of [false, true]) await runCase(config, encrypted);
|
|
}
|
|
|
|
main().catch((error: unknown) => {
|
|
console.error(error instanceof Error ? error.stack : error);
|
|
process.exitCode = 1;
|
|
});
|