/** * TDD tests for the WebDAV server (PR2, issue #3485). * * Tests cover the PURE logic exported by scripts/dev/webdav-handler.mjs: * - resolveVaultPath: path traversal guard * - verifyBasicAuth: correct/wrong/malformed creds, constant-time path * - decryptStored: values encrypted by src/lib/db/encryption.ts decrypt correctly * - buildPropfindXml: structure, special-char escaping, collection vs file * - PUT/GET round-trip, DELETE, MKCOL, MOVE via thin HTTP test harness * - disabled/no-creds → 503 (not served) * * Uses Node's native test runner (no vitest dependency). * Filesystem operations use a real temp dir (fs.mkdtempSync). */ import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import http from "node:http"; import { EventEmitter } from "node:events"; import { createCipheriv, randomBytes, scryptSync } from "node:crypto"; import { pathToFileURL } from "node:url"; // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── const HANDLER_PATH = path.resolve( path.dirname(new URL(import.meta.url).pathname), "../../scripts/dev/webdav-handler.mjs" ); /** Import the handler module fresh (bust module cache for env-dependent tests). */ async function importHandler(): Promise { const url = pathToFileURL(HANDLER_PATH).href; return import(`${url}?t=${Date.now()}-${Math.random().toString(36).slice(2)}`); } /** Encrypt a string with the OmniRoute enc:v1: format using the given secret. * Mirrors src/lib/db/encryption.ts: scrypt with static salt, AES-256-GCM. */ function encryptTs(secret: string, plaintext: string): string { const STATIC_SALT = "omniroute-field-encryption-v1"; const key = scryptSync(secret, STATIC_SALT, 32); const iv = randomBytes(16); const cipher = createCipheriv("aes-256-gcm", key, iv); let encrypted = cipher.update(plaintext, "utf8", "hex"); encrypted += cipher.final("hex"); const authTag = cipher.getAuthTag().toString("hex"); return `enc:v1:${iv.toString("hex")}:${encrypted}:${authTag}`; } /** Create a simple IncomingMessage-like object for unit tests. */ function fakeReq( method: string, url: string, headers: Record = {}, body: Buffer | null = null ): http.IncomingMessage { const req = Object.assign(new EventEmitter(), { method, url, headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])), socket: { remoteAddress: "127.0.0.1" }, }) as unknown as http.IncomingMessage; if (body !== null) { // Emit data/end on next tick so async consumers can wire listeners first setImmediate(() => { req.emit("data", body); req.emit("end"); }); } else { setImmediate(() => { req.emit("end"); }); } return req; } /** Capture a ServerResponse into { status, headers, body }. */ async function captureRes( req: http.IncomingMessage, handler: (req: http.IncomingMessage, res: http.ServerResponse) => Promise ): Promise<{ status: number; headers: http.OutgoingHttpHeaders; body: string }> { return new Promise((resolve) => { const chunks: Buffer[] = []; let status = 200; let headers: http.OutgoingHttpHeaders = {}; const res = { headersSent: false, writeHead(s: number, h: http.OutgoingHttpHeaders) { status = s; headers = h ?? {}; this.headersSent = true; }, end(data?: Buffer | string) { if (data) chunks.push(Buffer.isBuffer(data) ? data : Buffer.from(data)); resolve({ status, headers, body: Buffer.concat(chunks).toString("utf8") }); }, write(chunk: Buffer | string) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }, destroy() { resolve({ status, headers, body: Buffer.concat(chunks).toString("utf8") }); }, on() { return this; }, pipe(dest: NodeJS.WritableStream) { dest.end(); resolve({ status, headers, body: "" }); }, } as unknown as http.ServerResponse; handler(req, res).then(() => { // resolve was already called via res.end }); }); } /** Build a Basic-Auth Authorization header value. */ function basicAuth(user: string, pass: string): string { return "Basic " + Buffer.from(`${user}:${pass}`).toString("base64"); } // ───────────────────────────────────────────────────────────────────────────── // Setup // ───────────────────────────────────────────────────────────────────────────── const VAULT_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-vault-")); const ORIG_KEY = process.env.STORAGE_ENCRYPTION_KEY; test.after(() => { fs.rmSync(VAULT_ROOT, { recursive: true, force: true }); if (ORIG_KEY === undefined) { delete process.env.STORAGE_ENCRYPTION_KEY; } else { process.env.STORAGE_ENCRYPTION_KEY = ORIG_KEY; } }); // ───────────────────────────────────────────────────────────────────────────── // 1. Path traversal guard (resolveVaultPath) // ───────────────────────────────────────────────────────────────────────────── test("resolveVaultPath: simple path inside vault resolves correctly", async () => { const { resolveVaultPath } = await importHandler(); const { absPath } = resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/notes/file.md"); assert.equal(absPath, path.join(VAULT_ROOT, "notes", "file.md")); }); test("resolveVaultPath: root path resolves to vaultRoot", async () => { const { resolveVaultPath } = await importHandler(); const { absPath } = resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/"); assert.equal(absPath, path.resolve(VAULT_ROOT)); }); test("resolveVaultPath: ../ traversal is rejected with 403", async () => { const { resolveVaultPath } = await importHandler(); assert.throws( () => resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/../../../etc/passwd"), (err: { status: number }) => err.status === 403 ); }); test("resolveVaultPath: encoded %2e%2e%2f traversal is rejected with 403", async () => { const { resolveVaultPath } = await importHandler(); assert.throws( () => resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/%2e%2e%2fetc/passwd"), (err: { status: number }) => err.status === 403 ); }); test("resolveVaultPath: encoded %2e%2e traversal variant is rejected", async () => { const { resolveVaultPath } = await importHandler(); assert.throws( () => resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/%2e%2e/secret"), (err: { status: number }) => err.status === 403 ); }); test("resolveVaultPath: absolute path injection outside vault is rejected", async () => { const { resolveVaultPath } = await importHandler(); // The guard must catch paths that escape the vault root after resolution. // We test by passing a request path whose decoded form resolves outside VAULT_ROOT. // The URL segment /api/v1/webdav is stripped, then the remaining path is decoded // and resolved relative to vaultRoot. A path like /../../../outside must be caught. // Use an alternative vault root with a deeper subdir so we can craft an escape const deepVault = path.join(VAULT_ROOT, "sub", "deep"); // /api/v1/webdav/../../secret: after prefix strip → /../../secret // stripped of leading slashes → ../../secret // path.resolve(deepVault, "../../secret") → VAULT_ROOT/secret which is OUTSIDE deepVault assert.throws( () => resolveVaultPath(deepVault, "/api/v1/webdav/../../secret"), (err: { status: number }) => err.status === 403 ); }); test("resolveDestinationPath: raw path that escapes vault is rejected", async () => { const { resolveDestinationPath } = await importHandler(); // Use a deep vault so we can craft an escape path. // Pass a raw path (not a full URL) so the URL parser does not normalise away the .. const deepVault = path.join(VAULT_ROOT, "sub", "deep"); // A raw Destination header path that resolves outside deepVault assert.throws( () => resolveDestinationPath(deepVault, "/api/v1/webdav/../../escape-target"), (err: { status: number }) => err.status === 403 ); }); // ───────────────────────────────────────────────────────────────────────────── // 2. verifyBasicAuth // ───────────────────────────────────────────────────────────────────────────── test("verifyBasicAuth: correct credentials return true", async () => { const { verifyBasicAuth } = await importHandler(); assert.equal(verifyBasicAuth(basicAuth("alice", "s3cr3t"), "alice", "s3cr3t"), true); }); test("verifyBasicAuth: wrong password returns false", async () => { const { verifyBasicAuth } = await importHandler(); assert.equal(verifyBasicAuth(basicAuth("alice", "wrong"), "alice", "s3cr3t"), false); }); test("verifyBasicAuth: wrong username returns false", async () => { const { verifyBasicAuth } = await importHandler(); assert.equal(verifyBasicAuth(basicAuth("bob", "s3cr3t"), "alice", "s3cr3t"), false); }); test("verifyBasicAuth: missing header returns false", async () => { const { verifyBasicAuth } = await importHandler(); assert.equal(verifyBasicAuth(undefined, "alice", "s3cr3t"), false); }); test("verifyBasicAuth: empty header returns false", async () => { const { verifyBasicAuth } = await importHandler(); assert.equal(verifyBasicAuth("", "alice", "s3cr3t"), false); }); test("verifyBasicAuth: non-Basic scheme returns false", async () => { const { verifyBasicAuth } = await importHandler(); assert.equal( verifyBasicAuth("Bearer some-token", "alice", "s3cr3t"), false ); }); test("verifyBasicAuth: malformed base64 returns false", async () => { const { verifyBasicAuth } = await importHandler(); assert.equal(verifyBasicAuth("Basic !!!notbase64!!!", "alice", "s3cr3t"), false); }); test("verifyBasicAuth: base64 with no colon separator returns false", async () => { const { verifyBasicAuth } = await importHandler(); const noColon = "Basic " + Buffer.from("alices3cr3t").toString("base64"); assert.equal(verifyBasicAuth(noColon, "alice", "s3cr3t"), false); }); test("verifyBasicAuth: constant-time path exercised (long password diff)", async () => { const { verifyBasicAuth } = await importHandler(); const longPassword = "a".repeat(1000); // Should return false without throwing on length mismatch assert.equal(verifyBasicAuth(basicAuth("alice", longPassword), "alice", "short"), false); }); // ───────────────────────────────────────────────────────────────────────────── // 3. decryptStored (port of encryption.ts) // ───────────────────────────────────────────────────────────────────────────── test("decryptStored: plaintext passthrough when no enc: prefix", async () => { delete process.env.STORAGE_ENCRYPTION_KEY; const { decryptStored } = await importHandler(); assert.equal(decryptStored("plaintext-password"), "plaintext-password"); }); test("decryptStored: null input returns null", async () => { delete process.env.STORAGE_ENCRYPTION_KEY; const { decryptStored } = await importHandler(); assert.equal(decryptStored(null), null); }); test("decryptStored: value encrypted by TS encrypt() decrypts correctly", async () => { const SECRET = "webdav-test-encryption-key-32chars-ok"; process.env.STORAGE_ENCRYPTION_KEY = SECRET; const encrypted = encryptTs(SECRET, "my-vault-password"); const { decryptStored } = await importHandler(); const result = decryptStored(encrypted); assert.equal(result, "my-vault-password"); }); test("decryptStored: encrypted value with no key configured returns null", async () => { delete process.env.STORAGE_ENCRYPTION_KEY; // Even if a value has enc:v1: prefix, without the key we cannot decrypt const { decryptStored } = await importHandler(); const fakeEncrypted = "enc:v1:aabbccdd:eeff0011:22334455"; assert.equal(decryptStored(fakeEncrypted), null); }); test("decryptStored: malformed enc:v1: format returns null", async () => { process.env.STORAGE_ENCRYPTION_KEY = "some-key-value-here"; const { decryptStored } = await importHandler(); assert.equal(decryptStored("enc:v1:onlytwoparts:missing"), null); }); // ───────────────────────────────────────────────────────────────────────────── // 4. buildPropfindXml // ───────────────────────────────────────────────────────────────────────────── test("buildPropfindXml: produces valid XML with entries", async () => { const { buildPropfindXml } = await importHandler(); const now = new Date("2026-01-01T00:00:00Z"); const xml = buildPropfindXml( [ { name: "notes", href: "/api/v1/webdav/notes/", isDir: true, size: 0, mtime: now }, { name: "file.md", href: "/api/v1/webdav/notes/file.md", isDir: false, size: 1234, mtime: now, }, ], "/api/v1/webdav/" ); assert.match(xml, / { const { buildPropfindXml } = await importHandler(); const xml = buildPropfindXml( [ { name: "a & c 'quote' \"double\"", href: "/api/v1/webdav/a", isDir: false, size: 10, mtime: new Date(), }, ], "/api/v1/webdav/" ); // Unescaped < > & ' " must not appear inside element content assert.doesNotMatch(xml, /[^<]*<[^/]/); assert.match(xml, /</); assert.match(xml, />/); assert.match(xml, /&/); }); test("buildPropfindXml: file entry has no D:collection resourcetype", async () => { const { buildPropfindXml } = await importHandler(); const xml = buildPropfindXml( [{ name: "note.md", href: "/api/v1/webdav/note.md", isDir: false, size: 99, mtime: new Date() }], "/api/v1/webdav/" ); // File should have empty resourcetype, not a collection assert.doesNotMatch(xml, /D:collection/); }); // ───────────────────────────────────────────────────────────────────────────── // 5. Integration: PUT/GET/DELETE/MKCOL/MOVE with real temp vault // Uses maybeHandleWebdav via a fake DB config injected via loadWebdavConfig mock. // We test the pure HTTP dispatch against the real filesystem here. // ───────────────────────────────────────────────────────────────────────────── /** * Build a partial handler that bypasses DB (loadWebdavConfig) and uses a * hard-coded config for integration tests. We do this by wrapping maybeHandleWebdav * through a thin shim that patches the module's DB loader. * * Because ES module mocking is complex, we instead test the HTTP method handlers * directly via the private functions. Since those are not exported, we use a * small in-process test server that wires up the real maybeHandleWebdav with * a SQLite DB pre-populated with our test config. */ // Create a minimal SQLite DB for integration tests async function createTestDb( dataDir: string, opts: { enabled: boolean; username: string; password: string; vaultPath: string } ) { const { createRequire } = await import("node:module"); const _require = createRequire(import.meta.url); const Database = _require("better-sqlite3"); const dbPath = path.join(dataDir, "storage.sqlite"); const db = new Database(dbPath); db.exec(` CREATE TABLE IF NOT EXISTS key_value ( namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (namespace, key) ); `); const upsert = db.prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" ); upsert.run("obsidian", "webdav_enabled", JSON.stringify(opts.enabled)); upsert.run("obsidian", "webdav_username", JSON.stringify(opts.username)); upsert.run("obsidian", "webdav_password", JSON.stringify(opts.password)); upsert.run("obsidian", "vault_path", JSON.stringify(opts.vaultPath)); db.close(); return dbPath; } /** Run a single WebDAV request through the real handler with a test DB. */ async function webdavRequest(opts: { method: string; url: string; headers?: Record; body?: Buffer; dataDir: string; }): Promise<{ status: number; headers: http.OutgoingHttpHeaders; body: string }> { const origDataDir = process.env.DATA_DIR; process.env.DATA_DIR = opts.dataDir; try { const handler = await importHandler(); const req = fakeReq(opts.method, opts.url, opts.headers || {}, opts.body || null); return captureRes(req, (r, s) => handler.maybeHandleWebdav(r, s)); } finally { if (origDataDir === undefined) { delete process.env.DATA_DIR; } else { process.env.DATA_DIR = origDataDir; } } } const TEST_USER = "obsidian"; const TEST_PASS = "test-password-123"; const AUTH_HEADER = basicAuth(TEST_USER, TEST_PASS); // Create integration test environment const intDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-int-")); const intVaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-vault-int-")); test.before(async () => { await createTestDb(intDataDir, { enabled: true, username: TEST_USER, password: TEST_PASS, // plaintext (no enc: prefix — backward compat) vaultPath: intVaultDir, }); }); test.after(() => { fs.rmSync(intDataDir, { recursive: true, force: true }); fs.rmSync(intVaultDir, { recursive: true, force: true }); }); test("PUT then GET round-trips a file correctly", async () => { const content = Buffer.from("# Hello Obsidian\nThis is a test note.", "utf8"); const putRes = await webdavRequest({ method: "PUT", url: "/api/v1/webdav/hello.md", headers: { authorization: AUTH_HEADER, "content-length": String(content.length) }, body: content, dataDir: intDataDir, }); // 201 Created (new file) assert.ok([201, 204].includes(putRes.status), `PUT expected 201/204, got ${putRes.status}`); const getRes = await webdavRequest({ method: "GET", url: "/api/v1/webdav/hello.md", headers: { authorization: AUTH_HEADER }, dataDir: intDataDir, }); assert.equal(getRes.status, 200); assert.equal(getRes.body, content.toString("utf8")); }); test("PUT (update) returns 204, GET reflects updated content", async () => { // First create await webdavRequest({ method: "PUT", url: "/api/v1/webdav/update-me.md", headers: { authorization: AUTH_HEADER }, body: Buffer.from("original", "utf8"), dataDir: intDataDir, }); // Then update const updated = Buffer.from("updated content", "utf8"); const putRes2 = await webdavRequest({ method: "PUT", url: "/api/v1/webdav/update-me.md", headers: { authorization: AUTH_HEADER }, body: updated, dataDir: intDataDir, }); assert.equal(putRes2.status, 204); const getRes = await webdavRequest({ method: "GET", url: "/api/v1/webdav/update-me.md", headers: { authorization: AUTH_HEADER }, dataDir: intDataDir, }); assert.equal(getRes.body, "updated content"); }); test("DELETE removes a file, subsequent GET returns 404", async () => { await webdavRequest({ method: "PUT", url: "/api/v1/webdav/delete-me.md", headers: { authorization: AUTH_HEADER }, body: Buffer.from("temp", "utf8"), dataDir: intDataDir, }); const delRes = await webdavRequest({ method: "DELETE", url: "/api/v1/webdav/delete-me.md", headers: { authorization: AUTH_HEADER }, dataDir: intDataDir, }); assert.equal(delRes.status, 204); const getRes = await webdavRequest({ method: "GET", url: "/api/v1/webdav/delete-me.md", headers: { authorization: AUTH_HEADER }, dataDir: intDataDir, }); assert.equal(getRes.status, 404); }); test("MKCOL creates a directory", async () => { const mkcolRes = await webdavRequest({ method: "MKCOL", url: "/api/v1/webdav/newdir", headers: { authorization: AUTH_HEADER }, dataDir: intDataDir, }); assert.equal(mkcolRes.status, 201); const stat = fs.statSync(path.join(intVaultDir, "newdir")); assert.ok(stat.isDirectory()); }); test("MOVE renames a file", async () => { // Create source await webdavRequest({ method: "PUT", url: "/api/v1/webdav/move-src.md", headers: { authorization: AUTH_HEADER }, body: Buffer.from("move me", "utf8"), dataDir: intDataDir, }); const moveRes = await webdavRequest({ method: "MOVE", url: "/api/v1/webdav/move-src.md", headers: { authorization: AUTH_HEADER, destination: "http://localhost/api/v1/webdav/move-dst.md", }, dataDir: intDataDir, }); assert.ok([201, 204].includes(moveRes.status), `MOVE expected 201/204, got ${moveRes.status}`); assert.ok(!fs.existsSync(path.join(intVaultDir, "move-src.md")), "Source should not exist"); assert.ok(fs.existsSync(path.join(intVaultDir, "move-dst.md")), "Dest should exist"); }); test("PROPFIND on vault root returns 207 with entries", async () => { // Ensure at least one file exists fs.writeFileSync(path.join(intVaultDir, "propfind-test.md"), "content"); const propfindRes = await webdavRequest({ method: "PROPFIND", url: "/api/v1/webdav/", headers: { authorization: AUTH_HEADER, depth: "1" }, dataDir: intDataDir, }); assert.equal(propfindRes.status, 207); assert.match(propfindRes.body, /D:multistatus/); assert.match(propfindRes.body, /propfind-test\.md/); }); // ───────────────────────────────────────────────────────────────────────────── // 6. Auth failure paths // ───────────────────────────────────────────────────────────────────────────── test("GET without auth returns 401 with WWW-Authenticate", async () => { const res = await webdavRequest({ method: "GET", url: "/api/v1/webdav/hello.md", headers: {}, dataDir: intDataDir, }); assert.equal(res.status, 401); const wwwAuth = res.headers["WWW-Authenticate"] || res.headers["www-authenticate"]; assert.ok(typeof wwwAuth === "string" && wwwAuth.includes("Basic"), "WWW-Authenticate missing"); }); test("GET with wrong password returns 401", async () => { const res = await webdavRequest({ method: "GET", url: "/api/v1/webdav/hello.md", headers: { authorization: basicAuth(TEST_USER, "wrong-pass") }, dataDir: intDataDir, }); assert.equal(res.status, 401); }); // ───────────────────────────────────────────────────────────────────────────── // 7. Disabled / no-creds → not served // ───────────────────────────────────────────────────────────────────────────── test("disabled WebDAV returns 503", async () => { const disabledDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-disabled-")); const disabledVaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-disabled-vault-")); try { await createTestDb(disabledDataDir, { enabled: false, username: TEST_USER, password: TEST_PASS, vaultPath: disabledVaultDir, }); const res = await webdavRequest({ method: "GET", url: "/api/v1/webdav/file.md", headers: { authorization: AUTH_HEADER }, dataDir: disabledDataDir, }); assert.equal(res.status, 503); } finally { fs.rmSync(disabledDataDir, { recursive: true, force: true }); fs.rmSync(disabledVaultDir, { recursive: true, force: true }); } }); test("no DB / missing config returns 503", async () => { const emptyDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-empty-")); try { // No DB file at all const res = await webdavRequest({ method: "GET", url: "/api/v1/webdav/file.md", headers: { authorization: AUTH_HEADER }, dataDir: emptyDataDir, }); assert.equal(res.status, 503); } finally { fs.rmSync(emptyDataDir, { recursive: true, force: true }); } }); // ───────────────────────────────────────────────────────────────────────────── // 8. Non-WebDAV paths are not handled (returns false) // ───────────────────────────────────────────────────────────────────────────── test("non-webdav path returns false (not handled)", async () => { const origDataDir = process.env.DATA_DIR; process.env.DATA_DIR = intDataDir; try { const handler = await importHandler(); const req = fakeReq("GET", "/api/v1/chat/completions", {}); let capturedResult: boolean | undefined; const fakeRes = { headersSent: false, writeHead() {}, end() {}, write() {}, } as unknown as http.ServerResponse; capturedResult = await handler.maybeHandleWebdav(req, fakeRes); assert.equal(capturedResult, false); } finally { if (origDataDir === undefined) { delete process.env.DATA_DIR; } else { process.env.DATA_DIR = origDataDir; } } }); // ───────────────────────────────────────────────────────────────────────────── // 9. Encrypted password round-trip in integration // ───────────────────────────────────────────────────────────────────────────── test("encrypted password in DB is decrypted and auth works", async () => { const encSecret = "integration-enc-key-value-32chars!"; process.env.STORAGE_ENCRYPTION_KEY = encSecret; const encDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-enc-")); const encVaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-enc-vault-")); try { const encryptedPass = encryptTs(encSecret, TEST_PASS); await createTestDb(encDataDir, { enabled: true, username: TEST_USER, password: encryptedPass, vaultPath: encVaultDir, }); const res = await webdavRequest({ method: "OPTIONS", url: "/api/v1/webdav/", headers: { authorization: AUTH_HEADER }, dataDir: encDataDir, }); // OPTIONS with correct creds should succeed (200 or 207) assert.ok(res.status < 400, `Expected success with encrypted password, got ${res.status}`); } finally { fs.rmSync(encDataDir, { recursive: true, force: true }); fs.rmSync(encVaultDir, { recursive: true, force: true }); // Restore encryption key state if (ORIG_KEY === undefined) { delete process.env.STORAGE_ENCRYPTION_KEY; } else { process.env.STORAGE_ENCRYPTION_KEY = ORIG_KEY; } } }); // ───────────────────────────────────────────────────────────────────────────── // DATA_DIR resolution parity: the .mjs handler MUST resolve the same SQLite file // the app does, or WebDAV silently reads the wrong DB and 503s. Pin parity against // the real resolveDataDir() in src/lib/dataPaths.ts across env combinations. // ───────────────────────────────────────────────────────────────────────────── test("resolveDataDir: parity with src/lib/dataPaths.ts across env combos", async () => { const { resolveDataDir: mjsResolve } = await importHandler(); const { resolveDataDir: tsResolve } = await import("../../src/lib/dataPaths.ts"); const ORIG_DATA = process.env.DATA_DIR; const ORIG_XDG = process.env.XDG_CONFIG_HOME; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ddir-")); try { const combos: Array> = [ { DATA_DIR: tmp, XDG_CONFIG_HOME: undefined }, { DATA_DIR: ` ${tmp} `, XDG_CONFIG_HOME: undefined }, // trims + resolves { DATA_DIR: undefined, XDG_CONFIG_HOME: path.join(tmp, "xdg") }, { DATA_DIR: undefined, XDG_CONFIG_HOME: undefined }, // bare default ]; for (const combo of combos) { if (combo.DATA_DIR === undefined) delete process.env.DATA_DIR; else process.env.DATA_DIR = combo.DATA_DIR; if (combo.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = combo.XDG_CONFIG_HOME; const fromMjs = mjsResolve(); const fromTs = tsResolve(); assert.equal( fromMjs, fromTs, `DATA_DIR resolver diverged for ${JSON.stringify(combo)}: mjs=${fromMjs} ts=${fromTs}` ); } } finally { if (ORIG_DATA === undefined) delete process.env.DATA_DIR; else process.env.DATA_DIR = ORIG_DATA; if (ORIG_XDG === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = ORIG_XDG; fs.rmSync(tmp, { recursive: true, force: true }); } });