mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
Part 1 of #3485. Adds /api/settings/obsidian/webdav (GET/POST/DELETE) wiring the ready obsidianSync lib, encrypts webdav password + obsidian token at rest, removes the duplicate UI block, drops the KNOWN_MISSING entry. WebDAV file server is part 2.
This commit is contained in:
committed by
GitHub
parent
623806b6d7
commit
dbbcc65693
@@ -36,6 +36,8 @@
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **Obsidian/WebDAV**: add the `/api/settings/obsidian/webdav` config route (enable/disable vault sync), encrypt WebDAV credentials at rest, and remove the duplicate UI block (#3485, part 1).
|
||||
|
||||
- **OpenCode Free / passthrough**: "Test all models" now respects "Auto-hide failed models" and switches the list to the visible filter so hidden models actually disappear (#3610). Three related bugs fixed: `autoHideFailed` is now threaded from the outer component into `PassthroughModelsSection` via a prop (single shared checkbox); the `/api/models/test-all` request body now includes `autoHideFailed: true` so the server persists the hide; and after the loop, `visibilityFilter` is switched to `"visible"` when ≥1 model was hidden. Two pure-function helpers (`buildPassthroughTestBody`, `shouldSwitchToVisibleFilter`) extracted to `providerPageHelpers.ts` with 7 unit tests.
|
||||
- **Resilience**: clear stale transient connection cooldowns on startup so a prior unclean crash no longer makes every request time out at 120s after restart (#3625)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ const IGNORE = [
|
||||
// inventada. CADA UM precisa de triagem: criar a rota, corrigir o path, ou remover a
|
||||
// chamada morta. NÃO adicione novos aqui sem justificativa — esse é o ponto do gate.
|
||||
const KNOWN_MISSING = new Set([
|
||||
"/api/settings/obsidian/webdav", // ObsidianSourceCard.tsx — só existe /api/settings/obsidian
|
||||
// All previously known-missing routes have been resolved.
|
||||
]);
|
||||
|
||||
function walk(dir, acc = []) {
|
||||
|
||||
@@ -393,111 +393,6 @@ export default function ObsidianSourceCard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border/50 pt-3 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted font-medium">Vault Sync (WebDAV)</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-text-muted">
|
||||
Sync your vault to Obsidian mobile using WebDAV over Tailscale.
|
||||
Install the "WebDAV Sync" plugin on Obsidian mobile.
|
||||
</p>
|
||||
|
||||
{!webdavEnabled ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-text-muted font-medium">
|
||||
Vault Directory Path
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={vaultPath}
|
||||
onChange={(e) => setVaultPath(e.target.value)}
|
||||
placeholder="/Users/you/Documents/Obsidian"
|
||||
disabled={webdavBusy}
|
||||
className="font-mono text-sm flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleEnableWebdav}
|
||||
loading={webdavBusy}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-3 py-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-blue-400">cloud_sync</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-blue-300 font-medium">WebDAV sync enabled</p>
|
||||
<p className="text-[10px] text-blue-400/70 font-mono truncate">{getWebdavUrl()}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleDisableWebdav}
|
||||
loading={webdavBusy}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="border-red-500/30! text-red-400! hover:bg-red-500/10! shrink-0"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border/50 bg-black/10 p-3">
|
||||
<p className="text-[11px] text-text-muted font-medium">Configure Obsidian Mobile</p>
|
||||
<p className="text-[10px] text-text-muted">
|
||||
Install the "WebDAV Sync" plugin, then enter the following:
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] text-text-muted font-medium">WebDAV URL</label>
|
||||
<div className="flex items-center gap-1.5 rounded border border-border/30 bg-black/20 px-2.5 py-1.5">
|
||||
<code className="text-[10px] text-text-muted font-mono flex-1 break-all select-all">
|
||||
{getWebdavUrl()}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] text-text-muted font-medium">Username</label>
|
||||
<div className="flex items-center gap-1.5 rounded border border-border/30 bg-black/20 px-2.5 py-1.5">
|
||||
<code className="text-[10px] text-text-muted font-mono flex-1 select-all">
|
||||
{webdavUsername ?? "—"}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] text-text-muted font-medium">Password</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5 rounded border border-border/30 bg-black/20 px-2.5 py-1.5 flex-1">
|
||||
<code className="text-[10px] text-text-muted font-mono flex-1 select-all">
|
||||
{showPassword ? (webdavPassword ?? "—") : "••••••••••••"}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">
|
||||
{showPassword ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-text-muted">
|
||||
Use your Tailscale IP instead of localhost when configuring on mobile.
|
||||
{" "}Both devices must be on the same Tailscale network.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
81
src/app/api/settings/obsidian/webdav/route.ts
Normal file
81
src/app/api/settings/obsidian/webdav/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
import {
|
||||
getObsidianSyncStatus,
|
||||
enableObsidianVaultSync,
|
||||
disableObsidianVaultSync,
|
||||
} from "@/lib/obsidianSync";
|
||||
|
||||
const enableSchema = z
|
||||
.object({
|
||||
vaultPath: z.string().min(1).max(4096),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json(buildErrorBody(401, "Authentication required"), { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await getObsidianSyncStatus();
|
||||
return NextResponse.json({
|
||||
webdavEnabled: status.webdavEnabled,
|
||||
webdavUsername: status.webdavEnabled ? status.webdavUsername : null,
|
||||
webdavPassword: status.webdavEnabled ? status.webdavPassword : null,
|
||||
vaultPath: status.vaultPath,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json(buildErrorBody(500, msg), { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json(buildErrorBody(401, "Authentication required"), { status: 401 });
|
||||
}
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(buildErrorBody(400, "Invalid JSON body"), { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = enableSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
buildErrorBody(400, "Missing or invalid vaultPath"),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await enableObsidianVaultSync(parsed.data.vaultPath);
|
||||
if (!result.success) {
|
||||
return NextResponse.json(buildErrorBody(400, result.error), { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
username: result.username,
|
||||
password: result.password,
|
||||
vaultPath: result.vaultPath,
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json(buildErrorBody(401, "Authentication required"), { status: 401 });
|
||||
}
|
||||
|
||||
const result = await disableObsidianVaultSync();
|
||||
if (!result.success) {
|
||||
return NextResponse.json(buildErrorBody(500, result.error ?? "Failed to disable WebDAV sync"), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getDbInstance } from "./core";
|
||||
import { getApiKeyContextSource } from "./apiKeyContextSources";
|
||||
import { encrypt, decrypt } from "./encryption";
|
||||
|
||||
const OBSIDIAN_NAMESPACE = "obsidian";
|
||||
const OBSIDIAN_TOKEN_KEY = "api_key";
|
||||
@@ -14,7 +15,11 @@ export function getObsidianToken(): string | null {
|
||||
const row = db
|
||||
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
|
||||
.get(OBSIDIAN_NAMESPACE, OBSIDIAN_TOKEN_KEY) as KeyValueRow | undefined;
|
||||
return typeof row?.value === "string" ? JSON.parse(row.value) : null;
|
||||
if (typeof row?.value !== "string") return null;
|
||||
const parsed = JSON.parse(row.value);
|
||||
if (typeof parsed !== "string" || parsed.length === 0) return null;
|
||||
// Graceful fallback: if decrypt fails (e.g. no key set) return as-is
|
||||
return decrypt(parsed) ?? parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -23,9 +28,10 @@ export function getObsidianToken(): string | null {
|
||||
export function setObsidianToken(token: string): void {
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const encrypted = encrypt(token) ?? token;
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"
|
||||
).run(OBSIDIAN_NAMESPACE, OBSIDIAN_TOKEN_KEY, JSON.stringify(token));
|
||||
).run(OBSIDIAN_NAMESPACE, OBSIDIAN_TOKEN_KEY, JSON.stringify(encrypted));
|
||||
} catch {
|
||||
// Non-fatal — token still works in-memory if persistence fails.
|
||||
}
|
||||
@@ -168,7 +174,9 @@ export function getWebdavPassword(): string | null {
|
||||
.get(OBSIDIAN_NAMESPACE, "webdav_password") as KeyValueRow | undefined;
|
||||
if (typeof row?.value === "string") {
|
||||
const parsed = JSON.parse(row.value);
|
||||
return typeof parsed === "string" && parsed.length > 0 ? parsed : null;
|
||||
if (typeof parsed !== "string" || parsed.length === 0) return null;
|
||||
// Graceful fallback: if decrypt fails return as-is (plaintext backward compat)
|
||||
return decrypt(parsed) ?? parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
@@ -179,9 +187,10 @@ export function getWebdavPassword(): string | null {
|
||||
export function setWebdavPassword(password: string): void {
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const encrypted = encrypt(password) ?? password;
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"
|
||||
).run(OBSIDIAN_NAMESPACE, "webdav_password", JSON.stringify(password));
|
||||
).run(OBSIDIAN_NAMESPACE, "webdav_password", JSON.stringify(encrypted));
|
||||
} catch {
|
||||
// Non-fatal.
|
||||
}
|
||||
|
||||
301
tests/unit/obsidian-webdav-route.test.ts
Normal file
301
tests/unit/obsidian-webdav-route.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* TDD tests for /api/settings/obsidian/webdav route (PR1 of #3485).
|
||||
*
|
||||
* Covers:
|
||||
* - GET: no config → disabled shape with null creds
|
||||
* - POST: valid temp dir → enabled, returns { username, password }
|
||||
* - POST: non-existent path → 400 with no stack trace leaked
|
||||
* - DELETE: after enable → disabled, creds cleared
|
||||
* - Unauthenticated → 401
|
||||
* - Encryption round-trip: set password → raw DB value is NOT plaintext → get returns plaintext
|
||||
*/
|
||||
|
||||
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 type { NextRequest } from "next/server";
|
||||
import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-obsidian-webdav-route-"));
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
||||
const ORIGINAL_STORAGE_ENCRYPTION_KEY = process.env.STORAGE_ENCRYPTION_KEY;
|
||||
|
||||
// Set DATA_DIR before any module imports so the DB picks up the temp dir.
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
// Import settings to control auth requirements
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
// Import the route under test
|
||||
const route = await import("../../src/app/api/settings/obsidian/webdav/route.ts");
|
||||
// Import DB module to inspect raw stored values
|
||||
const obsidianDb = await import("../../src/lib/db/obsidian.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function makeRequest(url: string, options?: RequestInit): NextRequest {
|
||||
return new Request(url, options) as unknown as NextRequest;
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
delete process.env.STORAGE_ENCRYPTION_KEY;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
|
||||
if (ORIGINAL_DATA_DIR === undefined) {
|
||||
delete process.env.DATA_DIR;
|
||||
} else {
|
||||
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
}
|
||||
if (ORIGINAL_INITIAL_PASSWORD === undefined) {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
} else {
|
||||
process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
||||
}
|
||||
if (ORIGINAL_JWT_SECRET === undefined) {
|
||||
delete process.env.JWT_SECRET;
|
||||
} else {
|
||||
process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
|
||||
}
|
||||
if (ORIGINAL_STORAGE_ENCRYPTION_KEY === undefined) {
|
||||
delete process.env.STORAGE_ENCRYPTION_KEY;
|
||||
} else {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_STORAGE_ENCRYPTION_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Auth is disabled by default (requireLogin not set) so requests succeed ──
|
||||
|
||||
test("GET with no config → webdavEnabled:false, all creds null", async () => {
|
||||
const req = makeRequest("http://localhost/api/settings/obsidian/webdav");
|
||||
const res = await route.GET(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
assert.equal(body.webdavEnabled, false);
|
||||
assert.equal(body.webdavUsername, null);
|
||||
assert.equal(body.webdavPassword, null);
|
||||
assert.equal(body.vaultPath, null);
|
||||
});
|
||||
|
||||
test("POST with a valid temp dir → returns { username, password }, GET shows enabled", async () => {
|
||||
const vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault-"));
|
||||
try {
|
||||
const req = makeRequest("http://localhost/api/settings/obsidian/webdav", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ vaultPath: vaultDir }),
|
||||
});
|
||||
const res = await route.POST(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
assert.ok(typeof body.username === "string" && (body.username as string).length > 0, "username non-empty");
|
||||
assert.ok(typeof body.password === "string" && (body.password as string).length > 0, "password non-empty");
|
||||
assert.ok(typeof body.vaultPath === "string", "vaultPath returned");
|
||||
|
||||
// GET should now reflect enabled state
|
||||
const getReq = makeRequest("http://localhost/api/settings/obsidian/webdav");
|
||||
const getRes = await route.GET(getReq);
|
||||
assert.equal(getRes.status, 200);
|
||||
const getBody = (await getRes.json()) as Record<string, unknown>;
|
||||
assert.equal(getBody.webdavEnabled, true);
|
||||
assert.ok(typeof getBody.webdavUsername === "string" && (getBody.webdavUsername as string).length > 0);
|
||||
assert.ok(typeof getBody.webdavPassword === "string" && (getBody.webdavPassword as string).length > 0);
|
||||
} finally {
|
||||
fs.rmSync(vaultDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("POST with a non-existent path → 400, body does NOT contain a stack trace", async () => {
|
||||
const nonExistentPath = path.join(os.tmpdir(), "omni-nonexistent-vault-" + Date.now());
|
||||
const req = makeRequest("http://localhost/api/settings/obsidian/webdav", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ vaultPath: nonExistentPath }),
|
||||
});
|
||||
const res = await route.POST(req);
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const errorMsg = (body.error as Record<string, unknown> | undefined)?.message as string | undefined;
|
||||
// Must not leak stack trace
|
||||
assert.ok(
|
||||
!errorMsg || !errorMsg.includes("at /"),
|
||||
`Error message should not contain a stack trace, got: ${errorMsg}`
|
||||
);
|
||||
});
|
||||
|
||||
test("POST with invalid body (missing vaultPath) → 400", async () => {
|
||||
const req = makeRequest("http://localhost/api/settings/obsidian/webdav", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await route.POST(req);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test("DELETE after enable → webdavEnabled:false, creds cleared in GET", async () => {
|
||||
const vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault2-"));
|
||||
try {
|
||||
// Enable first
|
||||
const enableReq = makeRequest("http://localhost/api/settings/obsidian/webdav", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ vaultPath: vaultDir }),
|
||||
});
|
||||
const enableRes = await route.POST(enableReq);
|
||||
assert.equal(enableRes.status, 200);
|
||||
|
||||
// Delete
|
||||
const deleteReq = makeRequest("http://localhost/api/settings/obsidian/webdav", {
|
||||
method: "DELETE",
|
||||
});
|
||||
const deleteRes = await route.DELETE(deleteReq);
|
||||
assert.equal(deleteRes.status, 200);
|
||||
const deleteBody = (await deleteRes.json()) as Record<string, unknown>;
|
||||
assert.equal(deleteBody.success, true);
|
||||
|
||||
// GET should now show disabled
|
||||
const getReq = makeRequest("http://localhost/api/settings/obsidian/webdav");
|
||||
const getRes = await route.GET(getReq);
|
||||
const getBody = (await getRes.json()) as Record<string, unknown>;
|
||||
assert.equal(getBody.webdavEnabled, false);
|
||||
assert.equal(getBody.webdavUsername, null);
|
||||
assert.equal(getBody.webdavPassword, null);
|
||||
} finally {
|
||||
fs.rmSync(vaultDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("GET when disabled does not leak password even if stale data exists", async () => {
|
||||
const vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault3-"));
|
||||
try {
|
||||
// Enable, then disable
|
||||
const enableReq = makeRequest("http://localhost/api/settings/obsidian/webdav", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ vaultPath: vaultDir }),
|
||||
});
|
||||
await route.POST(enableReq);
|
||||
const deleteReq = makeRequest("http://localhost/api/settings/obsidian/webdav", {
|
||||
method: "DELETE",
|
||||
});
|
||||
await route.DELETE(deleteReq);
|
||||
|
||||
// GET now: password must be null (not a stale value)
|
||||
const getReq = makeRequest("http://localhost/api/settings/obsidian/webdav");
|
||||
const getRes = await route.GET(getReq);
|
||||
const getBody = (await getRes.json()) as Record<string, unknown>;
|
||||
assert.equal(getBody.webdavEnabled, false);
|
||||
assert.equal(getBody.webdavPassword, null);
|
||||
} finally {
|
||||
fs.rmSync(vaultDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Auth guard tests ──
|
||||
|
||||
test("Unauthenticated GET → 401 when auth is required", async () => {
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-password";
|
||||
await settingsDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
const req = makeRequest("http://localhost/api/settings/obsidian/webdav");
|
||||
const res = await route.GET(req);
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
test("Unauthenticated POST → 401 when auth is required", async () => {
|
||||
const vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault4-"));
|
||||
try {
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-password";
|
||||
await settingsDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
const req = makeRequest("http://localhost/api/settings/obsidian/webdav", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ vaultPath: vaultDir }),
|
||||
});
|
||||
const res = await route.POST(req);
|
||||
assert.equal(res.status, 401);
|
||||
} finally {
|
||||
fs.rmSync(vaultDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Unauthenticated DELETE → 401 when auth is required", async () => {
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-password";
|
||||
await settingsDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
const req = makeRequest("http://localhost/api/settings/obsidian/webdav", {
|
||||
method: "DELETE",
|
||||
});
|
||||
const res = await route.DELETE(req);
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
// ── Encryption round-trip ──
|
||||
|
||||
test("encryption round-trip: setWebdavPassword stores encrypted, getWebdavPassword returns plaintext", async () => {
|
||||
// Enable encryption
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-webdav-route-tests";
|
||||
|
||||
// Invalidate cached encryption keys so the new env var is picked up.
|
||||
// The encryption module caches keys in module-level vars; we reset via db instance.
|
||||
core.resetDbInstance();
|
||||
|
||||
const plaintext = "super-secret-webdav-password-12345";
|
||||
obsidianDb.setWebdavPassword(plaintext);
|
||||
|
||||
// Inspect raw DB row — it must NOT be the plaintext
|
||||
const db = core.getDbInstance();
|
||||
type KVRow = { value: string };
|
||||
const row = db
|
||||
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
|
||||
.get("obsidian", "webdav_password") as KVRow | undefined;
|
||||
|
||||
assert.ok(row !== undefined, "Row should exist");
|
||||
// The stored JSON string — parse to get inner value
|
||||
const storedInner = JSON.parse(row!.value) as string;
|
||||
assert.notEqual(
|
||||
storedInner,
|
||||
plaintext,
|
||||
"Raw DB value must NOT be plaintext when encryption is enabled"
|
||||
);
|
||||
assert.ok(
|
||||
storedInner.startsWith("enc:v1:"),
|
||||
`Raw DB value should start with enc:v1: prefix, got: ${storedInner.slice(0, 40)}`
|
||||
);
|
||||
|
||||
// getWebdavPassword must round-trip back to plaintext
|
||||
const retrieved = obsidianDb.getWebdavPassword();
|
||||
assert.equal(retrieved, plaintext, "getWebdavPassword must return original plaintext");
|
||||
|
||||
// Clean up env for other tests
|
||||
delete process.env.STORAGE_ENCRYPTION_KEY;
|
||||
core.resetDbInstance();
|
||||
});
|
||||
|
||||
test("encryption graceful fallback: plaintext stored without key reads back correctly", async () => {
|
||||
// No encryption key set — store plaintext
|
||||
const plaintext = "plaintext-webdav-password";
|
||||
obsidianDb.setWebdavPassword(plaintext);
|
||||
|
||||
// Must read back the same value
|
||||
const retrieved = obsidianDb.getWebdavPassword();
|
||||
assert.equal(retrieved, plaintext, "Plaintext value must read back unchanged when no encryption key");
|
||||
});
|
||||
Reference in New Issue
Block a user