mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-28 22:37:08 +00:00
test: cover Adaptive WebDAV in real Obsidian
This commit is contained in:
@@ -18,9 +18,12 @@ export type SetupState = {
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
bucketPrefix: string;
|
||||
journalFormat: string;
|
||||
packReadPolicy: string;
|
||||
p2pEnabled: boolean;
|
||||
p2pRelays: string;
|
||||
p2pRoomId: string;
|
||||
webDAVactiveConnectionURI: string;
|
||||
};
|
||||
|
||||
export type SetupCaptureNames = {
|
||||
@@ -182,16 +185,16 @@ export async function captureAndStartInitialisation(
|
||||
? "Setup Complete: Preparing This P2P Device"
|
||||
: p2pAdditionalDevice
|
||||
? "Setup Complete: Preparing to Fetch from Another Device"
|
||||
: mode === "new"
|
||||
? "Setup Complete: Preparing to Initialise Server"
|
||||
: "Setup Complete: Preparing to Fetch Synchronisation Data";
|
||||
: mode === "new"
|
||||
? "Setup Complete: Preparing to Initialise Server"
|
||||
: "Setup Complete: Preparing to Fetch Synchronisation Data";
|
||||
const button = p2pFirstDevice
|
||||
? "Restart and Prepare This Device"
|
||||
: p2pAdditionalDevice
|
||||
? "Restart and Select Source Device"
|
||||
: mode === "new"
|
||||
? "Restart and Initialise Server"
|
||||
: "Restart and Fetch Data";
|
||||
: mode === "new"
|
||||
? "Restart and Initialise Server"
|
||||
: "Restart and Fetch Data";
|
||||
if (p2pAdditionalDevice) {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const modal = modalByTitle(page, title);
|
||||
@@ -342,9 +345,12 @@ export async function readSetupState(cliBinary: string, environment: NodeJS.Proc
|
||||
"endpoint:settings.endpoint||'',",
|
||||
"bucket:settings.bucket||'',",
|
||||
"bucketPrefix:settings.bucketPrefix||'',",
|
||||
"journalFormat:settings.journalFormat||'',",
|
||||
"packReadPolicy:settings.packReadPolicy||'',",
|
||||
"p2pEnabled:settings.P2P_Enabled===true,",
|
||||
"p2pRelays:settings.P2P_relays||'',",
|
||||
"p2pRoomId:settings.P2P_roomID||'',",
|
||||
"webDAVactiveConnectionURI:settings.webDAVactiveConnectionURI||'',",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseWebDAVObjectKeys, webDAVCollectionUrl } from "./webDAV.ts";
|
||||
|
||||
describe("WebDAV E2E helpers", () => {
|
||||
it("builds an encoded collection URL below the configured endpoint", () => {
|
||||
expect(
|
||||
webDAVCollectionUrl({ endpoint: "http://127.0.0.1:8088/dav/" }, "Adaptive Journal/run one/").toString()
|
||||
).toBe("http://127.0.0.1:8088/dav/Adaptive%20Journal/run%20one/");
|
||||
});
|
||||
|
||||
it("extracts only flat object keys below the exact collection", () => {
|
||||
const collection = new URL("http://127.0.0.1:8088/dav/run/");
|
||||
const xml = `<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response><d:href>/dav/run/</d:href></d:response>
|
||||
<d:response><d:href>/dav/run/a1~manifest.json</d:href></d:response>
|
||||
<d:response><d:href>/dav/run/a1~commit~writer~1.bin</d:href></d:response>
|
||||
<d:response><d:href>/dav/run/nested/ignored.bin</d:href></d:response>
|
||||
<d:response><d:href>/dav/sibling/ignored.bin</d:href></d:response>
|
||||
</d:multistatus>`;
|
||||
expect(parseWebDAVObjectKeys(xml, collection)).toEqual(["a1~commit~writer~1.bin", "a1~manifest.json"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export type WebDAVConfig = {
|
||||
endpoint: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
function parseEnvFile(content: string): Record<string, string> {
|
||||
const entries = content
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#"))
|
||||
.map((line) => {
|
||||
const equalsAt = line.indexOf("=");
|
||||
if (equalsAt < 0) return undefined;
|
||||
const key = line.slice(0, equalsAt).trim();
|
||||
const rawValue = line.slice(equalsAt + 1).trim();
|
||||
return [key, rawValue.replace(/^['"]|['"]$/gu, "")] as const;
|
||||
})
|
||||
.filter((entry): entry is readonly [string, string] => entry !== undefined);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function firstValue(values: Record<string, string | undefined>, keys: readonly string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = values[key]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function loadWebDAVConfig(envFile = ".test.env"): Promise<WebDAVConfig> {
|
||||
let fileValues: Record<string, string> = {};
|
||||
try {
|
||||
fileValues = parseEnvFile(await readFile(resolve(envFile), "utf8"));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
const values = { ...fileValues, ...process.env };
|
||||
return {
|
||||
endpoint: (firstValue(values, ["WEBDAV_ENDPOINT", "webdavEndpoint"]) ?? "http://127.0.0.1:8088/dav").replace(
|
||||
/\/+$/u,
|
||||
""
|
||||
),
|
||||
username: firstValue(values, ["WEBDAV_USERNAME", "webdavUsername"]) ?? "",
|
||||
password: firstValue(values, ["WEBDAV_PASSWORD", "webdavPassword"]) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function normalisePrefix(prefix: string): string[] {
|
||||
const parts = prefix
|
||||
.trim()
|
||||
.split("/")
|
||||
.filter((part) => part.length > 0);
|
||||
if (parts.some((part) => part === "." || part === "..")) {
|
||||
throw new TypeError("WebDAV E2E prefix must not contain dot path segments.");
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function webDAVCollectionUrl(config: Pick<WebDAVConfig, "endpoint">, prefix: string): URL {
|
||||
const url = new URL(`${config.endpoint.replace(/\/+$/u, "")}/`);
|
||||
if (url.search || url.hash) throw new TypeError("WebDAV E2E endpoint must not contain a query or fragment.");
|
||||
const baseParts = url.pathname.split("/").filter((part) => part.length > 0);
|
||||
const prefixParts = normalisePrefix(prefix);
|
||||
url.pathname = `/${[...baseParts, ...prefixParts].map((part) => encodeURIComponent(decodeURIComponent(part))).join("/")}/`;
|
||||
return url;
|
||||
}
|
||||
|
||||
function requestHeaders(config: WebDAVConfig, additional: HeadersInit = {}): Headers {
|
||||
const headers = new Headers(additional);
|
||||
if (config.username || config.password) {
|
||||
headers.set(
|
||||
"Authorization",
|
||||
`Basic ${Buffer.from(`${config.username}:${config.password}`, "utf8").toString("base64")}`
|
||||
);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function assertWebDAVReachable(config: WebDAVConfig): Promise<void> {
|
||||
const response = await fetch(`${config.endpoint}/`, {
|
||||
method: "PROPFIND",
|
||||
headers: requestHeaders(config, { Depth: "0" }),
|
||||
});
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
if (response.status !== 207) {
|
||||
throw new Error(`WebDAV fixture is not reachable: HTTP ${response.status}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeXmlText(value: string): string {
|
||||
return value
|
||||
.replace(/&/giu, "&")
|
||||
.replace(/</giu, "<")
|
||||
.replace(/>/giu, ">")
|
||||
.replace(/"/giu, '"')
|
||||
.replace(/'/giu, "'");
|
||||
}
|
||||
|
||||
export function parseWebDAVObjectKeys(xml: string, collectionUrl: URL): string[] {
|
||||
const hrefs = [
|
||||
...xml.matchAll(/<(?:[A-Za-z_][\w.-]*:)?href\b[^>]*>([\s\S]*?)<\/(?:[A-Za-z_][\w.-]*:)?href>/giu),
|
||||
].map((match) => decodeXmlText(match[1].trim()));
|
||||
const basePath = decodeURIComponent(collectionUrl.pathname);
|
||||
const keys = new Set<string>();
|
||||
for (const href of hrefs) {
|
||||
const path = decodeURIComponent(new URL(href, collectionUrl).pathname);
|
||||
if (!path.startsWith(basePath)) continue;
|
||||
const key = path.slice(basePath.length).replace(/\/$/u, "");
|
||||
if (key && !key.includes("/")) keys.add(key);
|
||||
}
|
||||
return [...keys].sort();
|
||||
}
|
||||
|
||||
export async function listWebDAVObjectKeys(config: WebDAVConfig, prefix: string): Promise<string[]> {
|
||||
const collectionUrl = webDAVCollectionUrl(config, prefix);
|
||||
const response = await fetch(collectionUrl, {
|
||||
method: "PROPFIND",
|
||||
headers: requestHeaders(config, { Depth: "1" }),
|
||||
});
|
||||
if (response.status !== 207) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new Error(`Could not list WebDAV E2E objects: HTTP ${response.status}.`);
|
||||
}
|
||||
return parseWebDAVObjectKeys(await response.text(), collectionUrl);
|
||||
}
|
||||
|
||||
export async function readWebDAVObjectText(config: WebDAVConfig, prefix: string, key: string): Promise<string> {
|
||||
if (key.includes("/") || key === "." || key === "..") {
|
||||
throw new TypeError("WebDAV E2E object keys must be flat names.");
|
||||
}
|
||||
const response = await fetch(new URL(encodeURIComponent(key), webDAVCollectionUrl(config, prefix)), {
|
||||
headers: requestHeaders(config),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Could not read WebDAV E2E object ${key}: HTTP ${response.status}.`);
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
export async function deleteWebDAVPrefix(config: WebDAVConfig, prefix: string): Promise<void> {
|
||||
const response = await fetch(webDAVCollectionUrl(config, prefix), {
|
||||
method: "DELETE",
|
||||
headers: requestHeaders(config),
|
||||
});
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
if (!response.ok && response.status !== 404 && response.status !== 410) {
|
||||
throw new Error(`Could not remove WebDAV E2E prefix: HTTP ${response.status}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function makeUniqueWebDAVPrefix(label: string): string {
|
||||
const random = Math.random().toString(36).slice(2, 8);
|
||||
return `${label}-${Date.now()}-${random}/`;
|
||||
}
|
||||
Reference in New Issue
Block a user