mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-15 04:30:12 +00:00
Require explicit opt-in for unencrypted content with E2EE
This commit is contained in:
1 parent
79ffe8f6b0
commit
8451102176
26 files changed
+1614
-532
No files matched your search
@@ -0,0 +1,936 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assertCouchDbReachable,
|
||||
createCouchDbDatabase,
|
||||
deleteCouchDbDatabase,
|
||||
fetchCouchDbDocument,
|
||||
loadCouchDbConfig,
|
||||
makeUniqueDatabaseName,
|
||||
putCouchDbDocument,
|
||||
type CouchDbConfig,
|
||||
type CouchDbDocument,
|
||||
} from "../runner/couchdb.ts";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
prepareRemote,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
|
||||
|
||||
const fixtureContent = "# Synthetic remote fixture\n\nPlaintext body used by the isolated E2EE policy E2E.\n";
|
||||
|
||||
type RemoteFixture = {
|
||||
fixtureId: string;
|
||||
path: string;
|
||||
metadataId: string;
|
||||
metadataRev: string;
|
||||
chunkId: string;
|
||||
chunkRev: string;
|
||||
};
|
||||
|
||||
type ReplicationOutcome = {
|
||||
succeeded: boolean;
|
||||
idle: boolean;
|
||||
};
|
||||
|
||||
type LocalOutcome = {
|
||||
metadataPresent: boolean;
|
||||
metadataRevision: string | null;
|
||||
chunkPresent: boolean;
|
||||
decodedPlaintextPresent: boolean;
|
||||
vaultFilePresent: boolean;
|
||||
vaultContentMatches: boolean;
|
||||
};
|
||||
|
||||
type VaultFileState = {
|
||||
present: boolean;
|
||||
contentMatches: boolean;
|
||||
};
|
||||
|
||||
type DirectFetchOutcome = {
|
||||
reflected: boolean;
|
||||
requestedChunkCount: number;
|
||||
returnedChunkCount: number;
|
||||
};
|
||||
|
||||
type E2eePolicyUiOutcome = {
|
||||
initialEncryptionEnabled: boolean;
|
||||
initialAcceptanceChecked: boolean;
|
||||
acceptanceRowInAdvanced: boolean;
|
||||
acceptanceInitiallyEnabled: boolean;
|
||||
acceptanceCheckedAfterClick: boolean;
|
||||
acceptanceDisabledWhenEncryptionOff: boolean;
|
||||
acceptanceCheckedWhenEncryptionOff: boolean;
|
||||
acceptanceEnabledWhenEncryptionOn: boolean;
|
||||
acceptanceCheckedWhenEncryptionOn: boolean;
|
||||
warningMentionsThirdParty: boolean;
|
||||
warningMentionsPassphrase: boolean;
|
||||
warningMentionsDatabaseWriteInjection: boolean;
|
||||
cancelReturnedFalse: boolean;
|
||||
savedAcceptanceUnchanged: boolean;
|
||||
};
|
||||
|
||||
function fixturePath(fixtureId: string): string {
|
||||
return `E2E/e2ee-plaintext-policy/${fixtureId}.md`;
|
||||
}
|
||||
|
||||
async function getMetadataId(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<string> {
|
||||
return await evalObsidianJson<string>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
"return JSON.stringify(await core.services.path.path2id(path));",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function injectPlaintextFixture(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
couchDb: CouchDbConfig,
|
||||
dbName: string,
|
||||
fixtureId: string
|
||||
): Promise<RemoteFixture> {
|
||||
const path = fixturePath(fixtureId);
|
||||
const metadataId = await getMetadataId(cliBinary, env, path);
|
||||
const chunkId = `h:e2ee-plaintext-policy-${randomUUID()}`;
|
||||
const now = Date.now();
|
||||
const metadata: CouchDbDocument = {
|
||||
_id: metadataId,
|
||||
path,
|
||||
type: "plain",
|
||||
ctime: now,
|
||||
mtime: now,
|
||||
size: Buffer.byteLength(fixtureContent, "utf8"),
|
||||
children: [chunkId],
|
||||
eden: {},
|
||||
};
|
||||
const leaf: CouchDbDocument = { _id: chunkId, type: "leaf", data: fixtureContent };
|
||||
|
||||
const metadataPut = await putCouchDbDocument(couchDb, dbName, metadata);
|
||||
const leafPut = await putCouchDbDocument(couchDb, dbName, leaf);
|
||||
const remoteMetadata = await fetchCouchDbDocument(couchDb, dbName, metadataId);
|
||||
const remoteLeaf = await fetchCouchDbDocument(couchDb, dbName, chunkId);
|
||||
|
||||
assertEqual(remoteMetadata.path === path, true, "The remote Metadata path did not match the synthetic fixture.");
|
||||
assertEqual(remoteMetadata.type === "plain", true, "The remote Metadata type was invalid.");
|
||||
assertEqual(
|
||||
remoteMetadata.size === Buffer.byteLength(fixtureContent, "utf8"),
|
||||
true,
|
||||
"The remote Metadata size was invalid."
|
||||
);
|
||||
assertEqual(
|
||||
remoteMetadata.children?.length === 1 && remoteMetadata.children[0] === chunkId,
|
||||
true,
|
||||
"The remote Metadata did not reference the injected leaf."
|
||||
);
|
||||
assertEqual(remoteLeaf.type === "leaf", true, "The injected remote document was not a leaf.");
|
||||
assertEqual(
|
||||
remoteLeaf.data === fixtureContent,
|
||||
true,
|
||||
"The injected remote leaf did not contain the synthetic fixture body."
|
||||
);
|
||||
assertEqual(!("e_" in remoteLeaf), true, "The injected remote leaf unexpectedly had the encryption marker.");
|
||||
assertEqual(
|
||||
remoteMetadata._rev === metadataPut.rev,
|
||||
true,
|
||||
"The remote Metadata revision did not match the injected document."
|
||||
);
|
||||
assertEqual(remoteLeaf._rev === leafPut.rev, true, "The remote leaf revision did not match the injected document.");
|
||||
|
||||
return {
|
||||
fixtureId,
|
||||
path,
|
||||
metadataId,
|
||||
metadataRev: metadataPut.rev,
|
||||
chunkId,
|
||||
chunkRev: leafPut.rev,
|
||||
};
|
||||
}
|
||||
|
||||
async function runOneShotReplication(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000)
|
||||
): Promise<ReplicationOutcome> {
|
||||
return await evalObsidianJson<ReplicationOutcome>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`const timeoutMs=${JSON.stringify(timeoutMs)};`,
|
||||
"const succeeded=!!(await core.services.replication.replicate(true));",
|
||||
"const deadline=Date.now()+timeoutMs;",
|
||||
"let state;",
|
||||
"do{",
|
||||
" state={",
|
||||
" finite:core.services.replicator.finiteReplicationActivityCount.value,",
|
||||
" bounded:core.services.replicator.boundedRemoteActivityCount.value,",
|
||||
" results:core.services.replication.replicationResultCount.value,",
|
||||
" databaseQueue:core.services.replication.databaseQueueCount.value,",
|
||||
" storageApplying:core.services.replication.storageApplyingCount.value,",
|
||||
" };",
|
||||
" if(state.finite===0&&state.bounded===0&&state.results===0&&state.databaseQueue===0&&state.storageApplying===0){",
|
||||
" return JSON.stringify({succeeded,idle:true});",
|
||||
" }",
|
||||
" await new Promise(resolve=>setTimeout(resolve,100));",
|
||||
"}while(Date.now()<deadline);",
|
||||
"return JSON.stringify({succeeded,idle:false});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function readLocalOutcome(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
vault: TemporaryVault,
|
||||
fixture: RemoteFixture
|
||||
): Promise<LocalOutcome> {
|
||||
const local = await evalObsidianJson<LocalOutcome>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`const path=${JSON.stringify(fixture.path)};`,
|
||||
`const metadataId=${JSON.stringify(fixture.metadataId)};`,
|
||||
`const chunkId=${JSON.stringify(fixture.chunkId)};`,
|
||||
`const content=${JSON.stringify(fixtureContent)};`,
|
||||
"const db=core.localDatabase.localDatabase;",
|
||||
"const rows=await db.allDocs({keys:[metadataId,chunkId],include_docs:true});",
|
||||
"const metadataRow=rows.rows.find(row=>row.id===metadataId&&row.doc&&!row.value?.deleted);",
|
||||
"const chunkRow=rows.rows.find(row=>row.id===chunkId&&row.doc&&!row.value?.deleted);",
|
||||
"const metadata=metadataRow?.doc;",
|
||||
"const chunk=chunkRow?.doc;",
|
||||
"const decoded=metadata&&chunk?await core.localDatabase.getDBEntryFromMeta(metadata,false,false,true).catch(()=>false):false;",
|
||||
"const decodedData=decoded?(Array.isArray(decoded.data)?decoded.data.join(''):decoded.data):undefined;",
|
||||
"const file=app.vault.getAbstractFileByPath(path);",
|
||||
"const decodedPlaintextPresent=decodedData===content;",
|
||||
"return JSON.stringify({",
|
||||
" metadataPresent:!!metadata,metadataRevision:metadata?._rev??null,chunkPresent:!!chunk,",
|
||||
" decodedPlaintextPresent,",
|
||||
" vaultFilePresent:!!file,",
|
||||
" vaultContentMatches:!!file&&(await app.vault.read(file))===content,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
const diskState = await readVaultFileState(vault, fixture.path);
|
||||
return {
|
||||
...local,
|
||||
vaultFilePresent: local.vaultFilePresent && diskState.present,
|
||||
vaultContentMatches: local.vaultContentMatches && diskState.contentMatches,
|
||||
};
|
||||
}
|
||||
|
||||
async function readVaultFileState(vault: TemporaryVault, path: string): Promise<VaultFileState> {
|
||||
try {
|
||||
return { present: true, contentMatches: (await readFile(join(vault.path, path), "utf8")) === fixtureContent };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { present: false, contentMatches: false };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPlaintextRefused(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
vault: TemporaryVault,
|
||||
fixture: RemoteFixture
|
||||
): Promise<void> {
|
||||
const outcome = await readLocalOutcome(cliBinary, env, vault, fixture);
|
||||
assertEqual(
|
||||
outcome.chunkPresent,
|
||||
false,
|
||||
"The unencrypted leaf entered the local database while acceptance was disabled."
|
||||
);
|
||||
assertEqual(
|
||||
outcome.decodedPlaintextPresent,
|
||||
false,
|
||||
"The synthetic plaintext body was readable from the local database while acceptance was disabled."
|
||||
);
|
||||
assertEqual(outcome.vaultFilePresent, false, "A file was reflected into the Vault while acceptance was disabled.");
|
||||
assertEqual(
|
||||
outcome.vaultContentMatches,
|
||||
false,
|
||||
"The synthetic plaintext body was reflected into the Vault while acceptance was disabled."
|
||||
);
|
||||
}
|
||||
|
||||
async function assertPlaintextAccepted(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
vault: TemporaryVault,
|
||||
fixture: RemoteFixture
|
||||
): Promise<void> {
|
||||
const outcome = await readLocalOutcome(cliBinary, env, vault, fixture);
|
||||
assertEqual(outcome.metadataPresent, true, "The accepted remote Metadata was missing from the local database.");
|
||||
assertEqual(
|
||||
outcome.metadataRevision === fixture.metadataRev,
|
||||
true,
|
||||
"The accepted Metadata revision changed unexpectedly."
|
||||
);
|
||||
assertEqual(outcome.chunkPresent, true, "The explicitly accepted leaf was not stored in the local database.");
|
||||
assertEqual(outcome.decodedPlaintextPresent, true, "The accepted body could not be read from the local database.");
|
||||
assertEqual(outcome.vaultFilePresent, true, "The explicitly accepted body did not create a Vault file.");
|
||||
assertEqual(outcome.vaultContentMatches, true, "The explicitly accepted body did not reach the Obsidian Vault.");
|
||||
}
|
||||
|
||||
async function applySettingsPatch(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
patch: Record<string, unknown>,
|
||||
expectedPolicy: boolean,
|
||||
expectedReplicatorReplacement: boolean
|
||||
): Promise<void> {
|
||||
const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
|
||||
const result = await evalObsidianJson<{
|
||||
realised: number;
|
||||
accepted: boolean;
|
||||
replicatorReplaced: boolean;
|
||||
patchApplied: boolean;
|
||||
diagnostics: {
|
||||
before: {
|
||||
coreEncrypt: boolean;
|
||||
serviceEncrypt: boolean;
|
||||
encryptMatches: boolean;
|
||||
coreAcceptUnencryptedContent: boolean;
|
||||
serviceAcceptUnencryptedContent: boolean;
|
||||
acceptUnencryptedContentMatches: boolean;
|
||||
configurationIdentityPresent: boolean;
|
||||
providerName: string | null;
|
||||
};
|
||||
after: {
|
||||
coreEncrypt: boolean;
|
||||
serviceEncrypt: boolean;
|
||||
encryptMatches: boolean;
|
||||
coreAcceptUnencryptedContent: boolean;
|
||||
serviceAcceptUnencryptedContent: boolean;
|
||||
acceptUnencryptedContentMatches: boolean;
|
||||
configurationIdentityPresent: boolean;
|
||||
providerName: string | null;
|
||||
};
|
||||
configurationIdentityEqual: boolean | null;
|
||||
};
|
||||
operationCalls: Record<string, number>;
|
||||
}>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const setting=core.services.setting;",
|
||||
"const rebuilder=core.rebuilder;",
|
||||
`const patch=${JSON.stringify(patch)};`,
|
||||
`const expectedPolicy=${JSON.stringify(expectedPolicy)};`,
|
||||
`const timeoutMs=${JSON.stringify(timeoutMs)};`,
|
||||
"const operationCalls={};",
|
||||
"const targets=[",
|
||||
" [rebuilder,'$fetchLocalDBFast','fastFetch'],",
|
||||
" [rebuilder,'$fetchLocal','fetch'],",
|
||||
" [rebuilder,'fetchLocal','fetchLocal'],",
|
||||
" [rebuilder,'fetchLocalDBFast','fetchLocalDBFast'],",
|
||||
" [rebuilder,'scheduleFetch','scheduleFetch'],",
|
||||
" [rebuilder,'$performRebuildDB','performRebuild'],",
|
||||
" [rebuilder,'rebuildRemote','rebuildRemote'],",
|
||||
" [rebuilder,'$rebuildRemote','rebuildRemoteInternal'],",
|
||||
" [rebuilder,'rebuildEverything','rebuildEverything'],",
|
||||
" [rebuilder,'$rebuildEverything','rebuildEverythingInternal'],",
|
||||
" [rebuilder,'scheduleRebuild','scheduleRebuild'],",
|
||||
" [rebuilder,'resetLocalDatabase','resetLocalDatabase'],",
|
||||
"];",
|
||||
"for(const [target,key] of [[rebuilder,'$fetchLocalDBFast'],[rebuilder,'$fetchLocal'],[rebuilder,'scheduleFetch'],[rebuilder,'$performRebuildDB'],[rebuilder,'scheduleRebuild']]){",
|
||||
" if(typeof target?.[key]!=='function')throw new Error('A required Fetch or Rebuild operation could not be observed.');",
|
||||
"}",
|
||||
"const restorers=[];",
|
||||
"for(const [target,key,label] of targets){",
|
||||
" if(typeof target?.[key]!=='function') continue;",
|
||||
" const descriptor=Object.getOwnPropertyDescriptor(target,key);",
|
||||
" const original=target[key];",
|
||||
" operationCalls[label]=0;",
|
||||
" Object.defineProperty(target,key,{configurable:true,writable:true,value:function(...args){operationCalls[label]++;return original.apply(this,args);}});",
|
||||
" restorers.push(()=>{if(descriptor)Object.defineProperty(target,key,descriptor);else delete target[key];});",
|
||||
"}",
|
||||
"const replicatorService=core.services.replicator;",
|
||||
"const before=replicatorService.getActiveReplicator();",
|
||||
"if(!before)throw new Error('No active CouchDB Replicator was available before settings apply.');",
|
||||
"const beforeContext=replicatorService.inspectActiveReplicatorContext?.();",
|
||||
"const readSettingsState=()=>{const coreEncrypt=core.settings.encrypt===true;const serviceEncrypt=setting.currentSettings().encrypt===true;const coreAcceptUnencryptedContent=core.settings.acceptUnencryptedContent===true;const serviceAcceptUnencryptedContent=setting.currentSettings().acceptUnencryptedContent===true;return {coreEncrypt,serviceEncrypt,encryptMatches:coreEncrypt===serviceEncrypt,coreAcceptUnencryptedContent,serviceAcceptUnencryptedContent,acceptUnencryptedContentMatches:coreAcceptUnencryptedContent===serviceAcceptUnencryptedContent};};",
|
||||
"const providerName=context=>{const name=context?.provider?.diagnosticName??context?.provider?.kind;return typeof name==='string'?name:null;};",
|
||||
"const beforeSettings=readSettingsState();",
|
||||
"const beforeIdentity=beforeContext?.configurationIdentity;",
|
||||
"let realised=0;",
|
||||
"let resolveRealised;",
|
||||
"const realisedPromise=new Promise(resolve=>{resolveRealised=resolve;});",
|
||||
"const unregister=setting.onSettingRealised.addHandler(()=>{realised++;resolveRealised();return Promise.resolve(true);});",
|
||||
"let timer;",
|
||||
"try{",
|
||||
" const apply=async()=>{await setting.applyPartial(patch,true);await realisedPromise;};",
|
||||
" await Promise.race([apply(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('Timed out waiting for settings to be realised.')),timeoutMs);})]);",
|
||||
" if(realised!==1)throw new Error('The setting-realisation handler did not run exactly once.');",
|
||||
" const after=replicatorService.getActiveReplicator();",
|
||||
" const afterContext=replicatorService.inspectActiveReplicatorContext?.();",
|
||||
" const afterSettings=readSettingsState();",
|
||||
" const afterIdentity=afterContext?.configurationIdentity;",
|
||||
" const actual=setting.currentSettings().acceptUnencryptedContent===true;",
|
||||
" const patchApplied=Object.entries(patch).every(([key,value])=>core.settings[key]===value&&setting.currentSettings()[key]===value);",
|
||||
" const identityPresent=typeof beforeIdentity==='string'&&typeof afterIdentity==='string';",
|
||||
" const providerNameBefore=providerName(beforeContext);",
|
||||
" const providerNameAfter=providerName(afterContext);",
|
||||
" return JSON.stringify({realised,accepted:actual,replicatorReplaced:!!after&&after!==before,patchApplied,diagnostics:{before:{...beforeSettings,configurationIdentityPresent:typeof beforeIdentity==='string',providerName:providerNameBefore},after:{...afterSettings,configurationIdentityPresent:typeof afterIdentity==='string',providerName:providerNameAfter},configurationIdentityEqual:identityPresent?beforeIdentity===afterIdentity:null},operationCalls});",
|
||||
"}finally{",
|
||||
" clearTimeout(timer);",
|
||||
" unregister();",
|
||||
" for(const restore of restorers.reverse())restore();",
|
||||
"}",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
assertEqual(result.realised, 1, "The settings change did not reach onSettingRealised.");
|
||||
assertEqual(result.accepted, expectedPolicy, "The plaintext policy setting was not applied.");
|
||||
assertEqual(result.patchApplied, true, "The requested setting patch was not reflected in both settings views.");
|
||||
assertEqual(
|
||||
result.replicatorReplaced,
|
||||
expectedReplicatorReplacement,
|
||||
`Applying the setting did not meet the expected active Replicator replacement behaviour. Safe diagnostics: ${JSON.stringify(result.diagnostics)}`
|
||||
);
|
||||
assertEqual(
|
||||
Object.values(result.operationCalls).every((count) => count === 0),
|
||||
true,
|
||||
"Changing the setting automatically invoked Fetch or Rebuild."
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAndReflectOnDemand(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
fixture: RemoteFixture,
|
||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000)
|
||||
): Promise<DirectFetchOutcome> {
|
||||
return await evalObsidianJson<DirectFetchOutcome>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`const path=${JSON.stringify(fixture.path)};`,
|
||||
`const chunkId=${JSON.stringify(fixture.chunkId)};`,
|
||||
`const timeoutMs=${JSON.stringify(timeoutMs)};`,
|
||||
"const metadata=await core.localDatabase.getDBEntryMeta(path,undefined,true);",
|
||||
"if(!metadata)throw new Error('The received Metadata is unavailable for direct Chunk retrieval.');",
|
||||
"const replicator=core.services.replicator.getActiveReplicator();",
|
||||
"if(!replicator||typeof replicator.fetchRemoteChunks!=='function')throw new Error('The active CouchDB Replicator has no direct Chunk retrieval method.');",
|
||||
"const descriptor=Object.getOwnPropertyDescriptor(replicator,'fetchRemoteChunks');",
|
||||
"const original=replicator.fetchRemoteChunks;",
|
||||
"let requestedChunkCount=0;let returnedChunkCount=0;",
|
||||
"replicator.fetchRemoteChunks=async function(ids,...args){",
|
||||
" if(ids.includes(chunkId))requestedChunkCount++;",
|
||||
" const result=await original.call(this,ids,...args);",
|
||||
" if(Array.isArray(result))returnedChunkCount+=result.filter(doc=>doc?._id===chunkId).length;",
|
||||
" return result;",
|
||||
"};",
|
||||
"let timer;",
|
||||
"try{",
|
||||
" const operation=core.serviceModules.fileHandler.dbToStorage(metadata,null,true);",
|
||||
" const reflected=!!(await Promise.race([operation,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('Timed out while reflecting the direct Chunk fixture.')),timeoutMs);})]));",
|
||||
" return JSON.stringify({reflected,requestedChunkCount,returnedChunkCount});",
|
||||
"}finally{",
|
||||
" clearTimeout(timer);",
|
||||
" if(descriptor)Object.defineProperty(replicator,'fetchRemoteChunks',descriptor);else delete replicator.fetchRemoteChunks;",
|
||||
"}",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function assertE2eeConfiguration(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
const settings = await evalObsidianJson<{
|
||||
encrypted: boolean;
|
||||
algorithm: string;
|
||||
acceptUnencryptedContent: boolean;
|
||||
}>(
|
||||
cliBinary,
|
||||
[
|
||||
"(()=>{",
|
||||
"const settings=app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();",
|
||||
"return JSON.stringify({encrypted:settings.encrypt===true,algorithm:settings.E2EEAlgorithm,acceptUnencryptedContent:settings.acceptUnencryptedContent===true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
assertEqual(settings.encrypted, true, "The E2EE fixture did not enable encryption.");
|
||||
assertEqual(settings.algorithm, "v2", "The E2EE fixture did not use V2.");
|
||||
assertEqual(settings.acceptUnencryptedContent, false, "The default plaintext policy did not deny acceptance.");
|
||||
}
|
||||
|
||||
async function assertNonChunkReferenceRefused(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
couchDb: CouchDbConfig,
|
||||
dbName: string,
|
||||
vault: TemporaryVault
|
||||
): Promise<string> {
|
||||
const fixtureId = `non-chunk-${randomUUID()}`;
|
||||
const path = fixturePath(fixtureId);
|
||||
const metadataId = await getMetadataId(cliBinary, env, path);
|
||||
const chunkId = `soft-deleted-${randomUUID()}.md`;
|
||||
const now = Date.now();
|
||||
const metadataPut = await putCouchDbDocument(couchDb, dbName, {
|
||||
_id: metadataId,
|
||||
path,
|
||||
type: "plain",
|
||||
ctime: now,
|
||||
mtime: now,
|
||||
size: Buffer.byteLength(fixtureContent, "utf8"),
|
||||
children: [chunkId],
|
||||
eden: {},
|
||||
});
|
||||
|
||||
// Receive the referring Metadata before the remote body exists, so the
|
||||
// subsequent read must use the on-demand arrival path rather than a local row.
|
||||
const replication = await runOneShotReplication(cliBinary, env);
|
||||
assertEqual(replication.idle, true, "The non-Chunk fixture Metadata did not reach application quiescence.");
|
||||
const bodyPut = await putCouchDbDocument(couchDb, dbName, {
|
||||
_id: chunkId,
|
||||
type: "plain",
|
||||
deleted: true,
|
||||
data: fixtureContent,
|
||||
});
|
||||
const fixture: RemoteFixture = {
|
||||
fixtureId,
|
||||
path,
|
||||
metadataId,
|
||||
metadataRev: metadataPut.rev,
|
||||
chunkId,
|
||||
chunkRev: bodyPut.rev,
|
||||
};
|
||||
const before = await readLocalOutcome(cliBinary, env, vault, fixture);
|
||||
assertEqual(before.metadataPresent, true, "The referring Metadata was not received.");
|
||||
assertEqual(before.chunkPresent, false, "The non-Chunk fixture was already present locally.");
|
||||
|
||||
const rejected = await fetchAndReflectOnDemand(cliBinary, env, fixture);
|
||||
assertEqual(rejected.requestedChunkCount > 0, true, "The non-Chunk fixture did not use on-demand retrieval.");
|
||||
assertEqual(rejected.returnedChunkCount > 0, true, "The control-exempt body did not reach Chunk validation.");
|
||||
const after = await readLocalOutcome(cliBinary, env, vault, fixture);
|
||||
const acceptance = {
|
||||
reflected: rejected.reflected,
|
||||
stored: after.chunkPresent,
|
||||
vaultFilePresent: after.vaultFilePresent,
|
||||
vaultContentMatches: after.vaultContentMatches,
|
||||
};
|
||||
assertEqual(
|
||||
Object.values(acceptance).some(Boolean),
|
||||
false,
|
||||
`A non-Chunk document was accepted as file content: ${JSON.stringify(acceptance)}`
|
||||
);
|
||||
await assertPlaintextRefused(cliBinary, env, vault, fixture);
|
||||
|
||||
// A corrected remote leaf remains retrievable after opting in to plaintext.
|
||||
const repaired = await putCouchDbDocument(couchDb, dbName, {
|
||||
_id: chunkId,
|
||||
_rev: bodyPut.rev,
|
||||
type: "leaf",
|
||||
data: fixtureContent,
|
||||
});
|
||||
await applySettingsPatch(cliBinary, env, { acceptUnencryptedContent: true }, true, true);
|
||||
const accepted = await fetchAndReflectOnDemand(cliBinary, env, { ...fixture, chunkRev: repaired.rev });
|
||||
assertEqual(accepted.requestedChunkCount > 0, true, "The corrected Chunk was not requested again.");
|
||||
assertEqual(accepted.reflected, true, "The corrected plaintext leaf was not reflected after opt-in.");
|
||||
await assertPlaintextAccepted(cliBinary, env, vault, fixture);
|
||||
return fixtureId;
|
||||
}
|
||||
|
||||
async function inspectE2eePolicyDialog(cliBinary: string, env: NodeJS.ProcessEnv): Promise<E2eePolicyUiOutcome> {
|
||||
const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
|
||||
return await evalObsidianJson<E2eePolicyUiOutcome>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const timeoutMs=",
|
||||
JSON.stringify(timeoutMs),
|
||||
";",
|
||||
"const deadline=Date.now()+timeoutMs;",
|
||||
"const waitFor=async(predicate,label,end=deadline)=>{while(Date.now()<end){const value=predicate();if(value)return value;await new Promise(resolve=>setTimeout(resolve,50));}throw new Error('Timed out while '+label+'.');};",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const initialAcceptance=core.settings.acceptUnencryptedContent;",
|
||||
"const manager=core.modules.find(module=>module.constructor.name==='SetupManager');",
|
||||
"if(typeof manager?.onlyE2EEConfiguration!=='function')throw new Error('Could not find SetupManager.onlyE2EEConfiguration.');",
|
||||
"let dialogSettled=false;let dialogResult;let dialogFailure;let dialog=null;",
|
||||
"const dialogPromise=manager.onlyE2EEConfiguration('unknown',{...core.settings});",
|
||||
"void dialogPromise.then(result=>{dialogResult=result;dialogSettled=true;},error=>{dialogFailure=error;dialogSettled=true;});",
|
||||
"try{",
|
||||
" dialog=await waitFor(()=>document.querySelector('.sls-e2ee-dialog'),'opening the End-to-End Encryption dialogue');",
|
||||
" const findRow=label=>Array.from(dialog.querySelectorAll('label.row')).find(row=>row.querySelector('span')?.textContent?.trim()===label);",
|
||||
" const e2eeRow=findRow('End-to-End Encryption');",
|
||||
" if(!e2eeRow)throw new Error('The E2EE checkbox label row was not rendered.');",
|
||||
" const e2eeInput=e2eeRow.querySelector('input[type=checkbox]');",
|
||||
" if(!e2eeInput)throw new Error('The E2EE checkbox was not rendered.');",
|
||||
" const details=Array.from(dialog.querySelectorAll('details')).find(element=>element.querySelector('summary')?.textContent?.trim()==='Advanced');",
|
||||
" if(!details)throw new Error('The Advanced details section was not rendered.');",
|
||||
" details.querySelector('summary').click();",
|
||||
" await waitFor(()=>details.open,'opening the Advanced details section');",
|
||||
" const acceptanceRow=findRow('Accept unencrypted content');",
|
||||
" if(!acceptanceRow)throw new Error('The plaintext acceptance checkbox label row was not rendered.');",
|
||||
" const acceptanceInput=acceptanceRow.querySelector('input[type=checkbox]');",
|
||||
" if(!acceptanceInput)throw new Error('The plaintext acceptance checkbox was not rendered.');",
|
||||
" const acceptanceRowInAdvanced=details.contains(acceptanceRow);",
|
||||
" const initialEncryptionEnabled=e2eeInput.checked;",
|
||||
" const initialAcceptanceChecked=acceptanceInput.checked;",
|
||||
" const acceptanceInitiallyEnabled=!acceptanceInput.disabled;",
|
||||
" const warningText=details.textContent??'';",
|
||||
" const warningMentionsThirdParty=warningText.includes('third-party tools can add content');",
|
||||
" const warningMentionsPassphrase=warningText.includes('without the E2EE passphrase');",
|
||||
" const warningMentionsDatabaseWriteInjection=warningText.includes('Anyone with database write access can inject plaintext');",
|
||||
" acceptanceInput.click();",
|
||||
" await waitFor(()=>acceptanceInput.checked,'enabling plaintext acceptance');",
|
||||
" const acceptanceCheckedAfterClick=acceptanceInput.checked;",
|
||||
" e2eeInput.click();",
|
||||
" await waitFor(()=>!e2eeInput.checked&&acceptanceInput.disabled&&acceptanceInput.checked,'disabling E2EE while retaining plaintext acceptance');",
|
||||
" const acceptanceDisabledWhenEncryptionOff=acceptanceInput.disabled;",
|
||||
" const acceptanceCheckedWhenEncryptionOff=acceptanceInput.checked;",
|
||||
" e2eeInput.click();",
|
||||
" await waitFor(()=>e2eeInput.checked&&!acceptanceInput.disabled&&acceptanceInput.checked,'re-enabling E2EE with plaintext acceptance retained');",
|
||||
" const acceptanceEnabledWhenEncryptionOn=!acceptanceInput.disabled;",
|
||||
" const acceptanceCheckedWhenEncryptionOn=acceptanceInput.checked;",
|
||||
" const cancel=Array.from(dialog.querySelectorAll('button')).find(button=>button.textContent?.trim()==='Cancel');",
|
||||
" if(!cancel)throw new Error('The E2EE dialogue Cancel button was not rendered.');",
|
||||
" cancel.click();",
|
||||
" await waitFor(()=>dialogSettled,'waiting for SetupManager dialogue completion');",
|
||||
" if(dialogFailure)throw new Error('SetupManager dialogue failed.');",
|
||||
" return JSON.stringify({initialEncryptionEnabled,initialAcceptanceChecked,acceptanceRowInAdvanced,acceptanceInitiallyEnabled,acceptanceCheckedAfterClick,acceptanceDisabledWhenEncryptionOff,acceptanceCheckedWhenEncryptionOff,acceptanceEnabledWhenEncryptionOn,acceptanceCheckedWhenEncryptionOn,warningMentionsThirdParty,warningMentionsPassphrase,warningMentionsDatabaseWriteInjection,cancelReturnedFalse:dialogResult===false,savedAcceptanceUnchanged:core.settings.acceptUnencryptedContent===initialAcceptance&&core.services.setting.currentSettings().acceptUnencryptedContent===initialAcceptance});",
|
||||
"}finally{",
|
||||
" if(!dialogSettled){",
|
||||
" const root=dialog??document.querySelector('.sls-e2ee-dialog');",
|
||||
" const cancel=root&&Array.from(root.querySelectorAll('button')).find(button=>button.textContent?.trim()==='Cancel');",
|
||||
" if(cancel)cancel.click();",
|
||||
" await waitFor(()=>dialogSettled,'cleaning up the E2EE dialogue',Date.now()+timeoutMs);",
|
||||
" }",
|
||||
"}",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cliBinary = requireObsidianCli();
|
||||
const artifactRoot = process.env.E2E_OBSIDIAN_ARTIFACT_ROOT ?? process.cwd();
|
||||
const couchDb = await loadCouchDbConfig();
|
||||
await assertCouchDbReachable(couchDb);
|
||||
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "e2ee-plaintext-policy");
|
||||
const connection = { ...couchDb, dbName };
|
||||
const vault = await createTemporaryVault("obsidian-livesync-e2ee-plaintext-policy-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
let databaseCreated = false;
|
||||
|
||||
try {
|
||||
await createCouchDbDatabase(couchDb, dbName);
|
||||
databaseCreated = true;
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
artifactRoot,
|
||||
pluginData: createE2eCouchDbPluginData(connection, {
|
||||
encrypt: true,
|
||||
E2EEAlgorithm: "v2",
|
||||
passphrase: "synthetic-e2ee-plaintext-policy-fixture",
|
||||
acceptUnencryptedContent: false,
|
||||
encryptInternalMetadata: false,
|
||||
usePathObfuscation: false,
|
||||
readChunksOnline: false,
|
||||
useOnlyLocalChunk: false,
|
||||
useRequestAPI: true,
|
||||
periodicReplication: false,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
syncAfterMerge: false,
|
||||
}),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await prepareRemote(cliBinary, session.cliEnv);
|
||||
await assertE2eeConfiguration(cliBinary, session.cliEnv);
|
||||
const e2eePolicyUi = await inspectE2eePolicyDialog(cliBinary, session.cliEnv);
|
||||
assertEqual(
|
||||
e2eePolicyUi.initialEncryptionEnabled,
|
||||
true,
|
||||
"The E2EE configuration dialogue did not open with encryption enabled."
|
||||
);
|
||||
assertEqual(
|
||||
e2eePolicyUi.initialAcceptanceChecked,
|
||||
false,
|
||||
"The E2EE configuration dialogue did not open with plaintext acceptance disabled."
|
||||
);
|
||||
assertEqual(
|
||||
e2eePolicyUi.acceptanceRowInAdvanced,
|
||||
true,
|
||||
"The plaintext acceptance control was outside the Advanced details section."
|
||||
);
|
||||
assertEqual(
|
||||
e2eePolicyUi.acceptanceInitiallyEnabled,
|
||||
true,
|
||||
"The plaintext acceptance control was disabled while E2EE was enabled."
|
||||
);
|
||||
assertEqual(
|
||||
e2eePolicyUi.acceptanceCheckedAfterClick,
|
||||
true,
|
||||
"The plaintext acceptance control did not bind its checked state."
|
||||
);
|
||||
assertEqual(
|
||||
e2eePolicyUi.acceptanceDisabledWhenEncryptionOff,
|
||||
true,
|
||||
"The plaintext acceptance control remained enabled while E2EE was disabled."
|
||||
);
|
||||
assertEqual(
|
||||
e2eePolicyUi.acceptanceCheckedWhenEncryptionOff,
|
||||
true,
|
||||
"Disabling E2EE discarded the selected plaintext acceptance value."
|
||||
);
|
||||
assertEqual(
|
||||
e2eePolicyUi.acceptanceEnabledWhenEncryptionOn,
|
||||
true,
|
||||
"The plaintext acceptance control remained disabled after re-enabling E2EE."
|
||||
);
|
||||
assertEqual(
|
||||
e2eePolicyUi.acceptanceCheckedWhenEncryptionOn,
|
||||
true,
|
||||
"Re-enabling E2EE discarded the selected plaintext acceptance value."
|
||||
);
|
||||
assertEqual(
|
||||
e2eePolicyUi.warningMentionsThirdParty,
|
||||
true,
|
||||
"The plaintext acceptance warning did not explain third-party content injection."
|
||||
);
|
||||
assertEqual(
|
||||
e2eePolicyUi.warningMentionsPassphrase,
|
||||
true,
|
||||
"The plaintext acceptance warning did not explain that the E2EE passphrase is not required."
|
||||
);
|
||||
assertEqual(
|
||||
e2eePolicyUi.warningMentionsDatabaseWriteInjection,
|
||||
true,
|
||||
"The plaintext acceptance warning did not explain database-write injection risk."
|
||||
);
|
||||
assertEqual(
|
||||
e2eePolicyUi.cancelReturnedFalse,
|
||||
true,
|
||||
"Cancelling the E2EE configuration dialogue did not return false."
|
||||
);
|
||||
assertEqual(
|
||||
e2eePolicyUi.savedAcceptanceUnchanged,
|
||||
true,
|
||||
"Cancelling the E2EE configuration dialogue changed the saved plaintext policy."
|
||||
);
|
||||
|
||||
const ordinary = await injectPlaintextFixture(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
couchDb,
|
||||
dbName,
|
||||
`ordinary-${randomUUID()}`
|
||||
);
|
||||
const ordinaryReplication = await runOneShotReplication(cliBinary, session.cliEnv);
|
||||
assertEqual(ordinaryReplication.idle, true, "The ordinary CouchDB pull did not reach application quiescence.");
|
||||
await assertPlaintextRefused(cliBinary, session.cliEnv, vault, ordinary);
|
||||
assertEqual(
|
||||
ordinaryReplication.succeeded,
|
||||
false,
|
||||
"The ordinary CouchDB pull did not fail when it received a forbidden plaintext leaf."
|
||||
);
|
||||
|
||||
await applySettingsPatch(cliBinary, session.cliEnv, { acceptUnencryptedContent: true }, true, true);
|
||||
await assertPlaintextRefused(cliBinary, session.cliEnv, vault, ordinary);
|
||||
|
||||
const acceptedReplication = await runOneShotReplication(cliBinary, session.cliEnv);
|
||||
assertEqual(
|
||||
acceptedReplication.succeeded,
|
||||
true,
|
||||
"The ordinary CouchDB pull did not accept the same remote plaintext leaf after policy apply."
|
||||
);
|
||||
assertEqual(
|
||||
acceptedReplication.idle,
|
||||
true,
|
||||
"The accepted ordinary CouchDB pull did not reach application quiescence."
|
||||
);
|
||||
const ordinaryRemoteMetadata = await fetchCouchDbDocument(couchDb, dbName, ordinary.metadataId);
|
||||
const ordinaryRemoteLeaf = await fetchCouchDbDocument(couchDb, dbName, ordinary.chunkId);
|
||||
assertEqual(
|
||||
ordinaryRemoteMetadata._rev === ordinary.metadataRev,
|
||||
true,
|
||||
"The ordinary Metadata revision changed during acceptance."
|
||||
);
|
||||
assertEqual(
|
||||
ordinaryRemoteLeaf._rev === ordinary.chunkRev,
|
||||
true,
|
||||
"The ordinary leaf revision changed during acceptance."
|
||||
);
|
||||
assertEqual(
|
||||
!("e_" in ordinaryRemoteLeaf),
|
||||
true,
|
||||
"The accepted ordinary remote leaf unexpectedly had the encryption marker."
|
||||
);
|
||||
await assertPlaintextAccepted(cliBinary, session.cliEnv, vault, ordinary);
|
||||
|
||||
await applySettingsPatch(cliBinary, session.cliEnv, { acceptUnencryptedContent: false }, false, true);
|
||||
await applySettingsPatch(cliBinary, session.cliEnv, { readChunksOnline: true }, false, false);
|
||||
const onDemand = await injectPlaintextFixture(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
couchDb,
|
||||
dbName,
|
||||
`on-demand-${randomUUID()}`
|
||||
);
|
||||
const onDemandReplication = await runOneShotReplication(cliBinary, session.cliEnv);
|
||||
assertEqual(
|
||||
onDemandReplication.idle,
|
||||
true,
|
||||
"The on-demand Metadata pull did not reach application quiescence."
|
||||
);
|
||||
const onDemandMetadata = await evalObsidianJson<{ present: boolean; revision: string | null }>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(onDemand.path)};`,
|
||||
"const metadata=await app.plugins.plugins['obsidian-livesync'].core.localDatabase.getDBEntryMeta(path,undefined,true);",
|
||||
"return JSON.stringify({present:!!metadata,revision:metadata?._rev??null});",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(onDemandMetadata.present, true, "The on-demand pull did not receive the valid Metadata.");
|
||||
assertEqual(
|
||||
onDemandMetadata.revision === onDemand.metadataRev,
|
||||
true,
|
||||
"The on-demand pull received an unexpected Metadata revision."
|
||||
);
|
||||
await assertPlaintextRefused(cliBinary, session.cliEnv, vault, onDemand);
|
||||
|
||||
const deniedDirectFetch = await fetchAndReflectOnDemand(cliBinary, session.cliEnv, onDemand);
|
||||
assertEqual(
|
||||
deniedDirectFetch.requestedChunkCount > 0,
|
||||
true,
|
||||
"The denied direct read did not request the remote Chunk."
|
||||
);
|
||||
assertEqual(deniedDirectFetch.returnedChunkCount, 0, "The denied direct read returned the plaintext Chunk.");
|
||||
assertEqual(deniedDirectFetch.reflected, false, "The denied direct read reflected the plaintext body.");
|
||||
await assertPlaintextRefused(cliBinary, session.cliEnv, vault, onDemand);
|
||||
const onDemandMetadataAfterDenial = await evalObsidianJson<{ present: boolean; revision: string | null }>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(onDemand.path)};`,
|
||||
"const metadata=await app.plugins.plugins['obsidian-livesync'].core.localDatabase.getDBEntryMeta(path,undefined,true);",
|
||||
"return JSON.stringify({present:!!metadata,revision:metadata?._rev??null});",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(onDemandMetadataAfterDenial.present, true, "The rejected on-demand read removed its Metadata.");
|
||||
assertEqual(
|
||||
onDemandMetadataAfterDenial.revision === onDemand.metadataRev,
|
||||
true,
|
||||
"The rejected on-demand read changed its Metadata revision."
|
||||
);
|
||||
|
||||
await applySettingsPatch(cliBinary, session.cliEnv, { acceptUnencryptedContent: true }, true, true);
|
||||
const acceptedDirectFetch = await fetchAndReflectOnDemand(cliBinary, session.cliEnv, onDemand);
|
||||
assertEqual(
|
||||
acceptedDirectFetch.requestedChunkCount > 0,
|
||||
true,
|
||||
"The accepted direct read did not request the remote Chunk again."
|
||||
);
|
||||
assertEqual(
|
||||
acceptedDirectFetch.returnedChunkCount > 0,
|
||||
true,
|
||||
"The accepted direct read did not receive the same remote Chunk."
|
||||
);
|
||||
assertEqual(
|
||||
acceptedDirectFetch.reflected,
|
||||
true,
|
||||
"The accepted direct read did not reflect the plaintext body."
|
||||
);
|
||||
const onDemandRemoteMetadata = await fetchCouchDbDocument(couchDb, dbName, onDemand.metadataId);
|
||||
const onDemandRemoteLeaf = await fetchCouchDbDocument(couchDb, dbName, onDemand.chunkId);
|
||||
assertEqual(
|
||||
onDemandRemoteMetadata._rev === onDemand.metadataRev,
|
||||
true,
|
||||
"The on-demand Metadata revision changed during acceptance."
|
||||
);
|
||||
assertEqual(
|
||||
onDemandRemoteLeaf._rev === onDemand.chunkRev,
|
||||
true,
|
||||
"The on-demand leaf revision changed during acceptance."
|
||||
);
|
||||
assertEqual(
|
||||
!("e_" in onDemandRemoteLeaf),
|
||||
true,
|
||||
"The accepted on-demand remote leaf unexpectedly had the encryption marker."
|
||||
);
|
||||
await assertPlaintextAccepted(cliBinary, session.cliEnv, vault, onDemand);
|
||||
|
||||
await applySettingsPatch(cliBinary, session.cliEnv, { acceptUnencryptedContent: false }, false, true);
|
||||
const nonChunkFixtureId = await assertNonChunkReferenceRefused(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
couchDb,
|
||||
dbName,
|
||||
vault
|
||||
);
|
||||
|
||||
const artifactHash = createHash("sha256")
|
||||
.update(new Uint8Array(await readFile(join(artifactRoot, "main.js"))))
|
||||
.digest("hex");
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
scenario: "e2ee-plaintext-policy",
|
||||
artifactHash,
|
||||
fixtureIds: [ordinary.fixtureId, onDemand.fixtureId, nonChunkFixtureId],
|
||||
assertions: {
|
||||
remoteLeafHadNoEncryptionMarker: true,
|
||||
defaultOrdinaryPullRejectedThePlaintextLeafWithoutStoringOrReflectingIt: true,
|
||||
policyApplyReachedSettingRealisedAndReplacedTheReplicatorWithoutFetchOrRebuild: true,
|
||||
sameRemoteMetadataAndLeafRevisionWereAcceptedAfterPolicyChange: true,
|
||||
onDemandMetadataArrivedBeforePlaintextWasReflected: true,
|
||||
directChunkReadWasDeniedThenRetriedAndReflectedAfterPolicyChange: true,
|
||||
e2eeDialogBoundThePlaintextPolicyAndCancelLeftItUnchanged: true,
|
||||
nonChunkReferenceWasRejectedBeforeStorageAndVaultReflection: true,
|
||||
correctedRemoteLeafRemainedRetrievableAfterOptIn: true,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
if (session) await session.app.stop();
|
||||
await vault.dispose();
|
||||
if (databaseCreated) await deleteCouchDbDatabase(couchDb, dbName);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in new issue
Block a user