mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-01 13:21:51 +00:00
A- Add more tests.
- Object Storage support has also been confirmed (and fixed) in CLI.
This commit is contained in:
@@ -151,7 +151,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
if (body.type === "text/plain") {
|
||||
process.stdout.write(await body.text());
|
||||
} else {
|
||||
process.stdout.write(Buffer.from(await body.arrayBuffer()));
|
||||
const buffer = Buffer.from(await body.arrayBuffer());
|
||||
process.stdout.write(new Uint8Array(buffer));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -178,7 +179,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
if (body.type === "text/plain") {
|
||||
process.stdout.write(await body.text());
|
||||
} else {
|
||||
process.stdout.write(Buffer.from(await body.arrayBuffer()));
|
||||
const buffer = Buffer.from(await body.arrayBuffer());
|
||||
process.stdout.write(new Uint8Array(buffer));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -236,8 +238,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
return entry.status === "available";
|
||||
})
|
||||
.map((entry: { rev: string }) => entry.rev);
|
||||
const pastRevisionsText =
|
||||
pastRevisions.length > 0 ? pastRevisions.map((rev: string) => `${rev}`) : ["N/A"];
|
||||
const pastRevisionsText = pastRevisions.length > 0 ? pastRevisions.map((rev: string) => `${rev}`) : ["N/A"];
|
||||
const out = {
|
||||
id: doc._id,
|
||||
revision: doc._rev ?? "",
|
||||
|
||||
204
src/apps/cli/commands/runCommand.unit.spec.ts
Normal file
204
src/apps/cli/commands/runCommand.unit.spec.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import * as processSetting from "@lib/API/processSetting";
|
||||
import { configURIBase } from "@lib/common/models/shared.const";
|
||||
import { DEFAULT_SETTINGS } from "@lib/common/types";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
import * as commandUtils from "./utils";
|
||||
|
||||
function createCoreMock() {
|
||||
return {
|
||||
services: {
|
||||
control: {
|
||||
activated: Promise.resolve(),
|
||||
applySettings: vi.fn(async () => {}),
|
||||
},
|
||||
setting: {
|
||||
applyPartial: vi.fn(async () => {}),
|
||||
},
|
||||
},
|
||||
serviceModules: {
|
||||
fileHandler: {
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
storeFileToDB: vi.fn(async () => true),
|
||||
},
|
||||
storageAccess: {
|
||||
readFileAuto: vi.fn(async () => ""),
|
||||
writeFileAuto: vi.fn(async () => {}),
|
||||
},
|
||||
databaseFileAccess: {
|
||||
fetch: vi.fn(async () => undefined),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeOptions(command: CLIOptions["command"], commandArgs: string[]): CLIOptions {
|
||||
return {
|
||||
command,
|
||||
commandArgs,
|
||||
databasePath: "/tmp/vault",
|
||||
verbose: false,
|
||||
force: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function createSetupURI(passphrase: string): Promise<string> {
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
couchDB_URI: "http://127.0.0.1:5984",
|
||||
couchDB_DBNAME: "livesync-test-db",
|
||||
couchDB_USER: "user",
|
||||
couchDB_PASSWORD: "pass",
|
||||
isConfigured: true,
|
||||
} as any;
|
||||
return await processSetting.encodeSettingsToSetupURI(settings, passphrase);
|
||||
}
|
||||
|
||||
describe("runCommand abnormal cases", () => {
|
||||
const context = {
|
||||
vaultPath: "/tmp/vault",
|
||||
settingsPath: "/tmp/vault/.livesync/settings.json",
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("pull returns false for non-existing path", async () => {
|
||||
const core = createCoreMock();
|
||||
core.serviceModules.fileHandler.dbToStorage.mockResolvedValue(false);
|
||||
|
||||
const result = await runCommand(makeOptions("pull", ["missing.md", "/tmp/out.md"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(core.serviceModules.fileHandler.dbToStorage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pull-rev throws on empty revision", async () => {
|
||||
const core = createCoreMock();
|
||||
|
||||
await expect(
|
||||
runCommand(makeOptions("pull-rev", ["file.md", "/tmp/out.md", " "]), {
|
||||
...context,
|
||||
core,
|
||||
})
|
||||
).rejects.toThrow("pull-rev requires a non-empty revision");
|
||||
});
|
||||
|
||||
it("pull-rev returns false for invalid revision", async () => {
|
||||
const core = createCoreMock();
|
||||
core.serviceModules.databaseFileAccess.fetch.mockResolvedValue(undefined);
|
||||
|
||||
const result = await runCommand(makeOptions("pull-rev", ["file.md", "/tmp/out.md", "9-invalid"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(core.serviceModules.databaseFileAccess.fetch).toHaveBeenCalledWith("file.md", "9-invalid", true);
|
||||
});
|
||||
|
||||
it("cat-rev throws on empty revision", async () => {
|
||||
const core = createCoreMock();
|
||||
|
||||
await expect(
|
||||
runCommand(makeOptions("cat-rev", ["file.md", " "]), {
|
||||
...context,
|
||||
core,
|
||||
})
|
||||
).rejects.toThrow("cat-rev requires a non-empty revision");
|
||||
});
|
||||
|
||||
it("cat-rev returns false for invalid revision", async () => {
|
||||
const core = createCoreMock();
|
||||
core.serviceModules.databaseFileAccess.fetch.mockResolvedValue(undefined);
|
||||
|
||||
const result = await runCommand(makeOptions("cat-rev", ["file.md", "9-invalid"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(core.serviceModules.databaseFileAccess.fetch).toHaveBeenCalledWith("file.md", "9-invalid", true);
|
||||
});
|
||||
|
||||
it("push rejects when source file does not exist", async () => {
|
||||
const core = createCoreMock();
|
||||
|
||||
await expect(
|
||||
runCommand(makeOptions("push", ["/tmp/livesync-missing-src-file.md", "dst.md"]), {
|
||||
...context,
|
||||
core,
|
||||
})
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("setup rejects invalid URI", async () => {
|
||||
const core = createCoreMock();
|
||||
|
||||
await expect(
|
||||
runCommand(makeOptions("setup", ["https://invalid.example/setup"]), {
|
||||
...context,
|
||||
core,
|
||||
})
|
||||
).rejects.toThrow(`setup URI must start with ${configURIBase}`);
|
||||
});
|
||||
|
||||
it("setup rejects empty passphrase", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.spyOn(commandUtils, "promptForPassphrase").mockRejectedValue(new Error("Passphrase is required"));
|
||||
|
||||
await expect(
|
||||
runCommand(makeOptions("setup", [`${configURIBase}dummy`]), {
|
||||
...context,
|
||||
core,
|
||||
})
|
||||
).rejects.toThrow("Passphrase is required");
|
||||
});
|
||||
|
||||
it("setup accepts URI generated by encodeSettingsToSetupURI", async () => {
|
||||
const core = createCoreMock();
|
||||
const passphrase = "correct-passphrase";
|
||||
const setupURI = await createSetupURI(passphrase);
|
||||
vi.spyOn(commandUtils, "promptForPassphrase").mockResolvedValue(passphrase);
|
||||
|
||||
const result = await runCommand(makeOptions("setup", [setupURI]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
const [appliedSettings, saveImmediately] = core.services.setting.applyPartial.mock.calls[0];
|
||||
expect(saveImmediately).toBe(true);
|
||||
expect(appliedSettings.couchDB_URI).toBe("http://127.0.0.1:5984");
|
||||
expect(appliedSettings.couchDB_DBNAME).toBe("livesync-test-db");
|
||||
expect(appliedSettings.isConfigured).toBe(true);
|
||||
expect(appliedSettings.useIndexedDBAdapter).toBe(false);
|
||||
});
|
||||
|
||||
it("setup rejects encoded URI when passphrase is wrong", async () => {
|
||||
const core = createCoreMock();
|
||||
const setupURI = await createSetupURI("correct-passphrase");
|
||||
vi.spyOn(commandUtils, "promptForPassphrase").mockResolvedValue("wrong-passphrase");
|
||||
|
||||
await expect(
|
||||
runCommand(makeOptions("setup", [setupURI]), {
|
||||
...context,
|
||||
core,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(core.services.setting.applyPartial).not.toHaveBeenCalled();
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,13 @@ export function toArrayBuffer(data: Buffer): ArrayBuffer {
|
||||
export function toVaultRelativePath(inputPath: string, vaultPath: string): string {
|
||||
const stripped = inputPath.replace(/^[/\\]+/, "");
|
||||
if (!path.isAbsolute(inputPath)) {
|
||||
return stripped.replace(/\\/g, "/");
|
||||
const normalized = stripped.replace(/\\/g, "/");
|
||||
const resolved = path.resolve(vaultPath, normalized);
|
||||
const rel = path.relative(vaultPath, resolved);
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
throw new Error(`Path ${inputPath} is outside of the local database directory`);
|
||||
}
|
||||
return rel.replace(/\\/g, "/");
|
||||
}
|
||||
const resolved = path.resolve(inputPath);
|
||||
const rel = path.relative(vaultPath, resolved);
|
||||
|
||||
29
src/apps/cli/commands/utils.unit.spec.ts
Normal file
29
src/apps/cli/commands/utils.unit.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as path from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toVaultRelativePath } from "./utils";
|
||||
|
||||
describe("toVaultRelativePath", () => {
|
||||
const vaultPath = path.resolve("/tmp/livesync-vault");
|
||||
|
||||
it("rejects absolute paths outside vault", () => {
|
||||
expect(() => toVaultRelativePath("/etc/passwd", vaultPath)).toThrow("outside of the local database directory");
|
||||
});
|
||||
|
||||
it("normalizes leading slash for absolute path inside vault", () => {
|
||||
const absoluteInsideVault = path.join(vaultPath, "notes", "foo.md");
|
||||
expect(toVaultRelativePath(absoluteInsideVault, vaultPath)).toBe("notes/foo.md");
|
||||
});
|
||||
|
||||
it("normalizes Windows-style separators", () => {
|
||||
expect(toVaultRelativePath("notes\\daily\\2026-03-12.md", vaultPath)).toBe("notes/daily/2026-03-12.md");
|
||||
});
|
||||
|
||||
it("returns vault-relative path for another absolute path inside vault", () => {
|
||||
const absoluteInsideVault = path.join(vaultPath, "docs", "inside.md");
|
||||
expect(toVaultRelativePath(absoluteInsideVault, vaultPath)).toBe("docs/inside.md");
|
||||
});
|
||||
|
||||
it("rejects relative path traversal that escapes vault", () => {
|
||||
expect(() => toVaultRelativePath("../escape.md", vaultPath)).toThrow("outside of the local database directory");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user