A- Add more tests.

- Object Storage support has also been confirmed (and fixed) in CLI.
This commit is contained in:
vorotamoroz
2026-03-12 18:20:55 +09:00
parent 5d80258a77
commit d4aedf59f3
13 changed files with 892 additions and 281 deletions

View File

@@ -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 ?? "",

View 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();
});
});

View File

@@ -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);

View 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");
});
});