Skip to content
Merged
102 changes: 101 additions & 1 deletion cli/src/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { tmpdir } from "node:os"
import { join } from "node:path"
import { describe, expect, it, onTestFinished, vi } from "vitest"

import { runInit } from "../init.js"
import { runInit, validatePublicUrl } from "../init.js"
import { pollHealth } from "../docker.js"
import { buildDockerNotInstalledMessage } from "../messages.js"

Expand Down Expand Up @@ -1426,3 +1426,103 @@ describe("runInit health-timeout returns starting status", () => {
expect(scripted.prints[0]).not.toContain("The server is running.")
})
})

describe("validatePublicUrl", () => {
it("accepts a valid https URL", () => {
expect(validatePublicUrl("https://vault.example.com")).toEqual({
kind: "ok",
url: "https://vault.example.com",
})
})

it("accepts a valid http URL with port", () => {
expect(validatePublicUrl("http://203.0.113.10:8000")).toEqual({
kind: "ok",
url: "http://203.0.113.10:8000",
})
})

it("strips a trailing slash", () => {
expect(validatePublicUrl("https://vault.example.com/")).toEqual({
kind: "ok",
url: "https://vault.example.com",
})
})

it("rejects a URL with username and password", () => {
expect(validatePublicUrl("https://user:pass@vault.example.com")).toEqual({
kind: "error",
message: "PUBLIC_URL must not contain credentials (user:password@).",
})
})

it("rejects a URL with username only", () => {
expect(validatePublicUrl("https://user@vault.example.com")).toEqual({
kind: "error",
message: "PUBLIC_URL must not contain credentials (user:password@).",
})
})

it("rejects a URL with password only", () => {
expect(validatePublicUrl("https://:pass@vault.example.com")).toEqual({
kind: "error",
message: "PUBLIC_URL must not contain credentials (user:password@).",
})
})

it("rejects a non-http URL", () => {
expect(validatePublicUrl("ws://vault.example.com")).toEqual({
kind: "error",
message:
"PUBLIC_URL must be a full http:// or https:// URL (e.g. https://vault.example.com).",
})
})

it("rejects a URL with a trailing /mcp path", () => {
expect(validatePublicUrl("https://vault.example.com/mcp")).toEqual({
kind: "error",
message:
"Leave /mcp off PUBLIC_URL — it's the base URL and the server adds /mcp itself (e.g. https://vault.example.com).",
})
})

it("rejects invalid syntax", () => {
expect(validatePublicUrl("not-a-url")).toEqual({
kind: "error",
message:
"PUBLIC_URL must be a full http:// or https:// URL (e.g. https://vault.example.com).",
})
})

it("rejects a URL with a query string", () => {
expect(validatePublicUrl("https://vault.example.com/?tab=2")).toEqual({
kind: "error",
message:
"PUBLIC_URL must be a bare origin or path — no query string (?...) or fragment (#...).",
})
})

it("rejects a URL with a hash fragment", () => {
expect(validatePublicUrl("https://vault.example.com/#section")).toEqual({
kind: "error",
message:
"PUBLIC_URL must be a bare origin or path — no query string (?...) or fragment (#...).",
})
})

it("rejects a bare trailing query delimiter", () => {
expect(validatePublicUrl("https://vault.example.com/?")).toEqual({
kind: "error",
message:
"PUBLIC_URL must be a bare origin or path — no query string (?...) or fragment (#...).",
})
})

it("rejects a bare trailing hash delimiter", () => {
expect(validatePublicUrl("https://vault.example.com/#")).toEqual({
kind: "error",
message:
"PUBLIC_URL must be a bare origin or path — no query string (?...) or fragment (#...).",
})
})
})
245 changes: 245 additions & 0 deletions cli/src/__tests__/integration/cli-pty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,3 +407,248 @@ describe("non-interactive commands", () => {
expect(result.transcript).toContain("docker run failed")
})
})

describe("input validation re-prompts", () => {
it("rejects glob characters in vault path and re-prompts", async () => {
const { vaultDir, configDir } = createPtyWorkDir()

const prompts: PtyPrompt[] = [
{ match: "How do you want to run", send: "\r", label: "mode → local" },
{
match: "Path to your Obsidian vault",
send: "/path/to/*vault\r",
label: "vault path → glob (rejected)",
},
{
match: "glob characters",
send: `${vaultDir}\r`,
label: "re-prompt → valid path",
},
{
match: "Where should I put the config",
send: `${configDir}\r`,
label: "config dir",
},
{
match: "Any optional settings",
send: "\r",
label: "optional settings → skip",
},
{ match: "Start the server now", send: "n\r", label: "start → no" },
]

const result = await drivePty({
args: ["init"],
workDir: vaultDir,
prompts,
})

expect(result.exitCode).toBe(0)
expect(result.promptsAnswered).toBe(result.totalPrompts)
expect(result.transcript).toContain(
"Vault path must not contain glob characters",
)
})

it("rejects credentials in PUBLIC_URL and re-prompts", async () => {
const { vaultDir, configDir } = createPtyWorkDir()

const prompts: PtyPrompt[] = [
{
match: "How do you want to run",
send: `${DOWN}\r`,
label: "mode → remote",
},
{
match: "Where should I put the config",
send: `${configDir}\r`,
label: "config dir",
},
{
match: "Public base URL",
send: "https://user:pass@vault.example.com\r",
label: "PUBLIC_URL → credentials (rejected)",
},
{
match: "credentials",
send: "https://vault.example.com\r",
label: "re-prompt → valid URL",
},
{
match: "Exact name of your Obsidian vault",
send: "TestVault\r",
label: "vault name",
},
{
match: "Generate the token now",
send: "n\r",
label: "auto-capture → no",
},
{ match: "end-to-end encryption", send: "n\r", label: "E2E → no" },
{
match: "Any optional settings",
send: "\r",
label: "optional settings → skip",
},
]

const result = await drivePty({
args: ["init"],
workDir: vaultDir,
prompts,
})

expect(result.exitCode).toBe(0)
expect(result.promptsAnswered).toBe(result.totalPrompts)
expect(result.transcript).toContain(
"PUBLIC_URL must not contain credentials",
)
})

it("rejects a query string in PUBLIC_URL and re-prompts", async () => {
const { vaultDir, configDir } = createPtyWorkDir()

const prompts: PtyPrompt[] = [
{
match: "How do you want to run",
send: `${DOWN}\r`,
label: "mode → remote",
},
{
match: "Where should I put the config",
send: `${configDir}\r`,
label: "config dir",
},
{
match: "Public base URL",
send: "https://vault.example.com/?tab=2\r",
label: "PUBLIC_URL → query string (rejected)",
},
{
match: "query string",
send: "https://vault.example.com\r",
label: "re-prompt → valid URL",
},
{
match: "Exact name of your Obsidian vault",
send: "TestVault\r",
label: "vault name",
},
{
match: "Generate the token now",
send: "n\r",
label: "auto-capture → no",
},
{ match: "end-to-end encryption", send: "n\r", label: "E2E → no" },
{
match: "Any optional settings",
send: "\r",
label: "optional settings → skip",
},
]

const result = await drivePty({
args: ["init"],
workDir: vaultDir,
prompts,
})

expect(result.exitCode).toBe(0)
expect(result.promptsAnswered).toBe(result.totalPrompts)
expect(result.transcript).toContain("no query string")
})

it("rejects traversal in MEMORY_DIR and re-prompts", async () => {
const { vaultDir, configDir } = createPtyWorkDir()

// MEMORY_DIR is index 1 in the settings list: 1× down, space, enter
const selectMemoryDir = DOWN + " \r"

const prompts: PtyPrompt[] = [
{ match: "How do you want to run", send: "\r", label: "mode → local" },
{
match: "Path to your Obsidian vault",
send: `${vaultDir}\r`,
label: "vault path",
},
{
match: "Where should I put the config",
send: `${configDir}\r`,
label: "config dir",
},
{
match: "Any optional settings",
send: selectMemoryDir,
label: "select MEMORY_DIR",
},
{
match: "Vault folder for the memory files",
send: "../secret\r",
label: "memory dir → traversal (rejected)",
},
{
match: "Path traversal",
send: "My Notes\r",
label: "re-prompt → valid folder",
},
{ match: "Start the server now", send: "n\r", label: "start → no" },
]

const result = await drivePty({
args: ["init"],
workDir: vaultDir,
prompts,
})

expect(result.exitCode).toBe(0)
expect(result.promptsAnswered).toBe(result.totalPrompts)
expect(result.transcript).toContain("Path traversal (..) is not allowed")
})

it("rejects digits outside brackets in DAILY_NOTES_FORMAT and re-prompts", async () => {
const { vaultDir, configDir } = createPtyWorkDir()

// DAILY_NOTES_FORMAT is index 3 in the settings list: 3× down, space, enter
const selectFormat = DOWN.repeat(3) + " \r"

const prompts: PtyPrompt[] = [
{ match: "How do you want to run", send: "\r", label: "mode → local" },
{
match: "Path to your Obsidian vault",
send: `${vaultDir}\r`,
label: "vault path",
},
{
match: "Where should I put the config",
send: `${configDir}\r`,
label: "config dir",
},
{
match: "Any optional settings",
send: selectFormat,
label: "select DAILY_NOTES_FORMAT",
},
{
match: "Filename date format",
send: "2024-MM-DD\r",
label: "format → digits (rejected)",
},
{
match: "Moment tokens",
send: "YYYY-MM-DD\r",
label: "re-prompt → valid format",
},
{ match: "Start the server now", send: "n\r", label: "start → no" },
]

const result = await drivePty({
args: ["init"],
workDir: vaultDir,
prompts,
})

expect(result.exitCode).toBe(0)
expect(result.promptsAnswered).toBe(result.totalPrompts)
expect(result.transcript).toContain("Date format should use Moment tokens")
})
})
Loading
Loading