mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-26 11:03:21 +00:00
501 lines
16 KiB
TypeScript
501 lines
16 KiB
TypeScript
import { fileURLToPath } from "node:url";
|
|
import {
|
|
decodeSettingsFromSetupURI,
|
|
DEFAULT_SETTINGS,
|
|
PREFERRED_JOURNAL_SYNC,
|
|
} from "./livesync-commonlib.ts";
|
|
import { generateSetupURI } from "./generate_setup_uri.ts";
|
|
|
|
function assert(condition: unknown, message: string): asserts condition {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
|
|
Deno.test("reads LIVESYNC_ variables before the unprefixed names", async () => {
|
|
const generated = await generateSetupURI({
|
|
LIVESYNC_HOST: "https://couch.example.test",
|
|
LIVESYNC_USER: "alice",
|
|
LIVESYNC_PASS: "couch-secret",
|
|
LIVESYNC_DB: "notes",
|
|
LIVESYNC_PASSPHRASE: "vault-secret",
|
|
hostname: "https://shell.example.test",
|
|
username: "shell-user",
|
|
password: "shell-secret",
|
|
database: "shell-db",
|
|
passphrase: "shell-passphrase",
|
|
uri_passphrase: "setup-secret",
|
|
});
|
|
const decoded = await decodeSettingsFromSetupURI(
|
|
generated.setupURI,
|
|
generated.setupPassphrase,
|
|
);
|
|
assert(decoded, "Commonlib could not decode the CouchDB Setup URI");
|
|
const effective = { ...DEFAULT_SETTINGS, ...decoded };
|
|
assert(
|
|
effective.couchDB_URI === "https://couch.example.test" &&
|
|
effective.couchDB_USER === "alice" &&
|
|
effective.couchDB_PASSWORD === "couch-secret" &&
|
|
effective.couchDB_DBNAME === "notes",
|
|
"the CouchDB connection did not come from the LIVESYNC_ variables",
|
|
);
|
|
assert(
|
|
effective.passphrase === "vault-secret",
|
|
"the Vault passphrase did not come from LIVESYNC_PASSPHRASE",
|
|
);
|
|
});
|
|
|
|
Deno.test("falls back to the unprefixed names when LIVESYNC_ variables are blank", async () => {
|
|
const generated = await generateSetupURI({
|
|
LIVESYNC_HOST: " ",
|
|
hostname: "https://couch.example.test",
|
|
username: "alice",
|
|
password: "couch-secret",
|
|
database: "notes",
|
|
passphrase: "vault-secret",
|
|
uri_passphrase: "setup-secret",
|
|
});
|
|
const decoded = await decodeSettingsFromSetupURI(
|
|
generated.setupURI,
|
|
generated.setupPassphrase,
|
|
);
|
|
assert(decoded, "Commonlib could not decode the CouchDB Setup URI");
|
|
const effective = { ...DEFAULT_SETTINGS, ...decoded };
|
|
assert(
|
|
effective.couchDB_URI === "https://couch.example.test" &&
|
|
effective.couchDB_USER === "alice" &&
|
|
effective.couchDB_PASSWORD === "couch-secret" &&
|
|
effective.couchDB_DBNAME === "notes" &&
|
|
effective.passphrase === "vault-secret",
|
|
"the generator did not fall back to the unprefixed names",
|
|
);
|
|
});
|
|
|
|
Deno.test("names both variables when a CouchDB value is missing", async () => {
|
|
let rejected = false;
|
|
try {
|
|
await generateSetupURI({ uri_passphrase: "setup-secret" });
|
|
} catch (error) {
|
|
rejected = error instanceof Error &&
|
|
error.message === "LIVESYNC_HOST (or hostname) is required";
|
|
}
|
|
assert(rejected, "the generator did not name both host variables");
|
|
});
|
|
|
|
Deno.test("generates an Object Storage Setup URI with a selected S3 profile", async () => {
|
|
const generated = await generateSetupURI({
|
|
remote_type: "s3",
|
|
endpoint: "https://objects.example.test",
|
|
access_key: "access-key",
|
|
secret_key: "secret-key",
|
|
bucket: "vault-data",
|
|
region: "auto",
|
|
bucket_prefix: "team-a",
|
|
passphrase: "vault-secret",
|
|
uri_passphrase: "setup-secret",
|
|
});
|
|
assert(
|
|
generated.mode === "ephemeral",
|
|
"the default Setup URI mode was not Ephemeral",
|
|
);
|
|
assert(
|
|
generated.usableUntil !== null && generated.usableUntil > Date.now(),
|
|
"the Ephemeral Setup URI did not report its usable end time",
|
|
);
|
|
const decoded = await decodeSettingsFromSetupURI(
|
|
generated.setupURI,
|
|
generated.setupPassphrase,
|
|
);
|
|
assert(decoded, "Commonlib could not decode the Object Storage Setup URI");
|
|
const effective = { ...DEFAULT_SETTINGS, ...decoded };
|
|
const recoveryCode = generated.idRecoveryCode;
|
|
assert(
|
|
typeof recoveryCode === "string" && recoveryCode.startsWith("sls-id-v1:"),
|
|
"the generator did not return an ID recovery code",
|
|
);
|
|
assert(
|
|
(effective as typeof effective & { idDerivationVersion?: number })
|
|
.idDerivationVersion === 1,
|
|
"the Setup URI did not enable independent IDs",
|
|
);
|
|
assert(
|
|
(effective as typeof effective & { idDerivationKey?: string })
|
|
.idDerivationKey ===
|
|
recoveryCode.slice("sls-id-v1:".length),
|
|
"the Setup URI did not contain the generated ID key",
|
|
);
|
|
assert(
|
|
effective.isConfigured,
|
|
"the Setup URI left the imported device unconfigured",
|
|
);
|
|
assert(
|
|
effective.customChunkSize === PREFERRED_JOURNAL_SYNC.customChunkSize,
|
|
"the Object Storage Setup URI did not use its Commonlib journal chunk-size preference",
|
|
);
|
|
assert(
|
|
effective.liveSync,
|
|
"Object Storage was not configured for live journal synchronisation",
|
|
);
|
|
assert(
|
|
effective.endpoint === "https://objects.example.test",
|
|
"the endpoint was not preserved",
|
|
);
|
|
assert(
|
|
effective.bucketPrefix === "team-a",
|
|
"the bucket prefix was not preserved",
|
|
);
|
|
|
|
const profiles = Object.values(decoded.remoteConfigurations ?? {});
|
|
assert(
|
|
profiles.length === 1,
|
|
"the Setup URI did not contain exactly one Object Storage profile",
|
|
);
|
|
assert(
|
|
decoded.activeConfigurationId === profiles[0].id,
|
|
"the Object Storage profile was not selected",
|
|
);
|
|
assert(
|
|
profiles[0].uri.startsWith("sls+s3://"),
|
|
"the selected profile was not an S3 connection URI",
|
|
);
|
|
});
|
|
|
|
Deno.test("generates a random-room P2P Setup URI without copying a device identity", async () => {
|
|
const generated = await generateSetupURI({
|
|
remote_type: "p2p",
|
|
passphrase: "vault-secret",
|
|
uri_passphrase: "setup-secret",
|
|
});
|
|
const decoded = await decodeSettingsFromSetupURI(
|
|
generated.setupURI,
|
|
generated.setupPassphrase,
|
|
);
|
|
assert(decoded, "Commonlib could not decode the P2P Setup URI");
|
|
const effective = { ...DEFAULT_SETTINGS, ...decoded };
|
|
assert(
|
|
(effective as typeof effective & { idDerivationKey?: string })
|
|
.idDerivationKey ===
|
|
generated.idRecoveryCode?.slice("sls-id-v1:".length),
|
|
"the P2P Setup URI did not contain the generated ID key",
|
|
);
|
|
assert(
|
|
/^\d{3}-\d{3}-\d{3}-[a-z0-9]{3}$/.test(effective.P2P_roomID),
|
|
"Commonlib did not generate the expected random room ID",
|
|
);
|
|
assert(
|
|
/^[A-Za-z0-9_-]{32}$/.test(effective.P2P_passphrase),
|
|
"the generated P2P passphrase was not a 32-character base64url secret",
|
|
);
|
|
assert(
|
|
!effective.P2P_AutoStart,
|
|
"P2P auto-start was enabled without an explicit request",
|
|
);
|
|
assert(
|
|
!effective.P2P_AutoBroadcast,
|
|
"P2P auto-broadcast was enabled without an explicit request",
|
|
);
|
|
assert(
|
|
effective.customChunkSize === 0,
|
|
"the P2P Setup URI did not retain its zero chunk-size value",
|
|
);
|
|
assert(
|
|
effective.sendChunksBulkMaxSize === 1,
|
|
"the P2P profile did not retain the conservative manual resend size",
|
|
);
|
|
assert(
|
|
!Object.hasOwn(decoded, "P2P_DevicePeerName"),
|
|
"the Setup URI copied a device-specific P2P peer name",
|
|
);
|
|
|
|
const profiles = Object.values(decoded.remoteConfigurations ?? {});
|
|
assert(
|
|
profiles.length === 1,
|
|
"the Setup URI did not contain exactly one P2P profile",
|
|
);
|
|
assert(
|
|
decoded.activeConfigurationId === profiles[0].id,
|
|
"the P2P profile was not selected as the main remote",
|
|
);
|
|
assert(
|
|
decoded.P2P_ActiveRemoteConfigurationId === profiles[0].id,
|
|
"the P2P profile was not selected for P2P features",
|
|
);
|
|
assert(
|
|
profiles[0].uri.startsWith("sls+p2p://"),
|
|
"the selected profile was not a P2P connection URI",
|
|
);
|
|
});
|
|
|
|
Deno.test("generates a Persistent Setup URI on explicit request", async () => {
|
|
const generated = await generateSetupURI({
|
|
remote_type: "p2p",
|
|
passphrase: "vault-secret",
|
|
uri_passphrase: "setup-secret",
|
|
uri_mode: "persistent",
|
|
});
|
|
assert(generated.mode === "persistent", "the explicit mode was not retained");
|
|
assert(
|
|
generated.usableUntil === null,
|
|
"Persistent unexpectedly has a time condition",
|
|
);
|
|
const decoded = await decodeSettingsFromSetupURI(
|
|
generated.setupURI,
|
|
generated.setupPassphrase,
|
|
);
|
|
assert(decoded, "the Persistent Setup URI could not be opened");
|
|
});
|
|
|
|
Deno.test("rejects an unknown Setup URI mode", async () => {
|
|
let rejected = false;
|
|
try {
|
|
await generateSetupURI({ uri_mode: "later" });
|
|
} catch (error) {
|
|
rejected = error instanceof Error &&
|
|
error.message === "uri_mode must be ephemeral or persistent";
|
|
}
|
|
assert(rejected, "the generator accepted an unknown Setup URI mode");
|
|
});
|
|
|
|
for (const mode of ["ephemeral", "persistent"] as const) {
|
|
Deno.test(`preserves ID recovery and explicit legacy IDs in ${mode} URIs`, async () => {
|
|
const environment = {
|
|
remote_type: "p2p",
|
|
uri_mode: mode,
|
|
passphrase: "vault-secret",
|
|
uri_passphrase: "setup-secret",
|
|
};
|
|
const first = await generateSetupURI(environment);
|
|
const second = await generateSetupURI({
|
|
...environment,
|
|
id_recovery_code: first.idRecoveryCode,
|
|
});
|
|
const independentlyGenerated = await generateSetupURI(environment);
|
|
assert(
|
|
second.idRecoveryCode === first.idRecoveryCode,
|
|
"the recovery code changed on repeat generation",
|
|
);
|
|
assert(
|
|
independentlyGenerated.idRecoveryCode !== first.idRecoveryCode,
|
|
"the default ID key was reused",
|
|
);
|
|
const repeatedSettings = await decodeSettingsFromSetupURI(
|
|
second.setupURI,
|
|
second.setupPassphrase,
|
|
);
|
|
assert(repeatedSettings, "the repeated Setup URI could not be decoded");
|
|
assert(
|
|
(repeatedSettings as typeof repeatedSettings & {
|
|
idDerivationKey?: string;
|
|
}).idDerivationKey ===
|
|
first.idRecoveryCode?.slice("sls-id-v1:".length),
|
|
"the recovery code did not restore the original ID key",
|
|
);
|
|
|
|
const legacy = await generateSetupURI({
|
|
...environment,
|
|
id_mode: "legacy",
|
|
});
|
|
const decoded = await decodeSettingsFromSetupURI(
|
|
legacy.setupURI,
|
|
legacy.setupPassphrase,
|
|
);
|
|
assert(decoded, "the legacy Setup URI could not be decoded");
|
|
assert(
|
|
legacy.idRecoveryCode === undefined,
|
|
"legacy mode returned an ID recovery code",
|
|
);
|
|
assert(
|
|
(decoded as typeof decoded & { idDerivationVersion?: number })
|
|
.idDerivationVersion !== 1,
|
|
"legacy mode enabled independent IDs",
|
|
);
|
|
let rejected = false;
|
|
try {
|
|
await generateSetupURI({
|
|
...environment,
|
|
id_recovery_code: "sls-id-v1:wrong",
|
|
});
|
|
} catch {
|
|
rejected = true;
|
|
}
|
|
assert(rejected, "an invalid recovery code was accepted");
|
|
rejected = false;
|
|
try {
|
|
await generateSetupURI({
|
|
...environment,
|
|
id_mode: "legacy",
|
|
id_recovery_code: first.idRecoveryCode,
|
|
});
|
|
} catch {
|
|
rejected = true;
|
|
}
|
|
assert(rejected, "legacy mode silently ignored a recovery code");
|
|
});
|
|
}
|
|
|
|
const couchDBConnection = {
|
|
hostname: "https://couch.example.test",
|
|
username: "alice",
|
|
password: "couch-secret",
|
|
database: "notes",
|
|
passphrase: "vault-secret",
|
|
};
|
|
const prefixedCouchDBConnection = {
|
|
LIVESYNC_HOST: couchDBConnection.hostname,
|
|
LIVESYNC_USER: couchDBConnection.username,
|
|
LIVESYNC_PASS: couchDBConnection.password,
|
|
LIVESYNC_DB: couchDBConnection.database,
|
|
LIVESYNC_PASSPHRASE: couchDBConnection.passphrase,
|
|
};
|
|
const commandEnvironments: { name: string; values: Record<string, string> }[] =
|
|
[
|
|
{ name: "prefixed values only", values: prefixedCouchDBConnection },
|
|
{
|
|
name: "prefixed values before conflicting legacy values",
|
|
values: {
|
|
...prefixedCouchDBConnection,
|
|
hostname: "https://shell.example.test",
|
|
username: "shell-user",
|
|
password: "shell-password",
|
|
database: "shell-database",
|
|
passphrase: "shell-passphrase",
|
|
},
|
|
},
|
|
{
|
|
name: "blank prefixed values fall back to legacy values",
|
|
values: {
|
|
...couchDBConnection,
|
|
LIVESYNC_HOST: " ",
|
|
LIVESYNC_USER: "\t",
|
|
LIVESYNC_PASS: " ",
|
|
LIVESYNC_DB: "\t",
|
|
LIVESYNC_PASSPHRASE: " ",
|
|
},
|
|
},
|
|
{ name: "compatibility: legacy values only", values: couchDBConnection },
|
|
];
|
|
|
|
async function runGeneratorCommand(
|
|
script: URL,
|
|
values: Record<string, string>,
|
|
) {
|
|
const environment = Deno.env.toObject();
|
|
const inputNames = new Set([
|
|
...Object.keys(couchDBConnection),
|
|
...Object.keys(prefixedCouchDBConnection),
|
|
"remote_type",
|
|
"uri_passphrase",
|
|
"uri_mode",
|
|
"id_mode",
|
|
"id_recovery_code",
|
|
"endpoint",
|
|
"access_key",
|
|
"secret_key",
|
|
"bucket",
|
|
"region",
|
|
"bucket_prefix",
|
|
"bucket_custom_headers",
|
|
"use_custom_request_handler",
|
|
"force_path_style",
|
|
"p2p_room_id",
|
|
"p2p_passphrase",
|
|
"p2p_relays",
|
|
"p2p_app_id",
|
|
"p2p_auto_start",
|
|
"p2p_auto_broadcast",
|
|
].map((name) => name.toLowerCase()));
|
|
for (const name of Object.keys(environment)) {
|
|
if (inputNames.has(name.toLowerCase())) delete environment[name];
|
|
}
|
|
const result = await new Deno.Command(Deno.execPath(), {
|
|
args: [
|
|
"run",
|
|
"--config",
|
|
fileURLToPath(new URL("../flyio/deno.jsonc", import.meta.url)),
|
|
"--frozen",
|
|
"--lock",
|
|
fileURLToPath(new URL("../flyio/deno.lock", import.meta.url)),
|
|
"--allow-env",
|
|
script.href,
|
|
],
|
|
clearEnv: true,
|
|
env: {
|
|
...environment,
|
|
uri_passphrase: "setup-secret",
|
|
uri_mode: "persistent",
|
|
...values,
|
|
},
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
}).output();
|
|
assert(
|
|
result.success,
|
|
`generator failed: ${new TextDecoder().decode(result.stderr)}`,
|
|
);
|
|
const output = new TextDecoder().decode(result.stdout);
|
|
const uri = output.match(/obsidian:\/\/setuplivesync\?settings=\S+/u)?.[0];
|
|
assert(uri, "the command did not print a Setup URI");
|
|
const decoded = await decodeSettingsFromSetupURI(uri, "setup-secret");
|
|
assert(decoded, "the command output could not be decoded");
|
|
return { ...DEFAULT_SETTINGS, ...decoded };
|
|
}
|
|
|
|
for (
|
|
const [name, path] of [
|
|
["Setup generator", "./generate_setup_uri.ts"],
|
|
["Fly.io wrapper", "../flyio/generate_setupuri.ts"],
|
|
]
|
|
) {
|
|
for (const scenario of commandEnvironments) {
|
|
Deno.test(`E2E ${name}: ${scenario.name}`, async () => {
|
|
const settings = await runGeneratorCommand(
|
|
new URL(path, import.meta.url),
|
|
scenario.values,
|
|
);
|
|
assert(
|
|
settings.couchDB_URI === couchDBConnection.hostname &&
|
|
settings.couchDB_USER === couchDBConnection.username &&
|
|
settings.couchDB_PASSWORD === couchDBConnection.password &&
|
|
settings.couchDB_DBNAME === couchDBConnection.database &&
|
|
settings.passphrase === couchDBConnection.passphrase,
|
|
"the command did not preserve the selected connection and Vault passphrase",
|
|
);
|
|
const profileId = settings.activeConfigurationId;
|
|
const profile = profileId
|
|
? settings.remoteConfigurations?.[profileId]
|
|
: undefined;
|
|
assert(
|
|
profile?.uri.startsWith("sls+https://"),
|
|
"the generated URI did not select its CouchDB profile",
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const remoteType of ["s3", "p2p"]) {
|
|
Deno.test(`E2E Setup generator: prefixed Vault passphrase for ${remoteType}`, async () => {
|
|
const settings = await runGeneratorCommand(
|
|
new URL("./generate_setup_uri.ts", import.meta.url),
|
|
{
|
|
remote_type: remoteType,
|
|
endpoint: "https://objects.example.test",
|
|
access_key: "access-key",
|
|
secret_key: "secret-key",
|
|
bucket: "vault-data",
|
|
LIVESYNC_PASSPHRASE: "vault-secret",
|
|
passphrase: "shell-passphrase",
|
|
},
|
|
);
|
|
assert(
|
|
settings.passphrase === "vault-secret",
|
|
"the command did not prefer LIVESYNC_PASSPHRASE",
|
|
);
|
|
const profileId = settings.activeConfigurationId;
|
|
const profile = profileId
|
|
? settings.remoteConfigurations?.[profileId]
|
|
: undefined;
|
|
assert(
|
|
profile?.uri.startsWith(`sls+${remoteType}://`),
|
|
"the command did not select the requested remote profile",
|
|
);
|
|
});
|
|
}
|