Files
obsidian-api/tests/test_api.py
T
daniel156161 902a3df8b5
Build and Push Docker Container / build-and-push (push) Successful in 4m26s
feat: add Obsidian CLI API service
- Add Docker image for the official Obsidian desktop app and CLI.

- Start Obsidian headlessly with Xvfb and DBus setup.

- Expose a safe structured HTTP command API without shell execution.

- Add JWT-based vault, path, and command authorization.

- Support single-vault and multi-vault container mounts.

- Add TypeScript SDK helpers for Obsidian CLI commands.

- Add n8n community node package with Obsidian operations.

- Add docs, compose config, tests, and production image workflow.
2026-06-24 09:11:13 +02:00

168 lines
5.5 KiB
Python

import hashlib, base64, json, hmac
import time, os
from starlette.testclient import TestClient
os.environ.setdefault("OBSIDIAN_JWT_SECRET", "test-jwt-secret")
os.environ.setdefault("OBSIDIAN_CLI_BIN", "python")
os.environ.setdefault("OBSIDIAN_VAULT_PATH", ".")
import app.auth as authmod # noqa: E402
from app.main import app # noqa: E402
client = TestClient(app)
def make_jwt(claims:dict, secret:bytes = b"test-jwt-secret") -> str:
header = {"alg": "HS256", "typ": "JWT"}
def enc(value:dict) -> str:
raw = json.dumps(value, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
signing_input = f"{enc(header)}.{enc(claims)}"
signature = hmac.new(secret, signing_input.encode(), hashlib.sha256).digest()
return f"{signing_input}.{base64.urlsafe_b64encode(signature).decode().rstrip('=')}"
def jwt(claims:dict|None = None) -> str:
claims = claims or {}
claims.setdefault("sub", "tests")
claims.setdefault("vaults", ["*"])
claims.setdefault("paths", ["*"])
claims.setdefault("commands", ["*"])
claims.setdefault("exp", int(time.time()) + 3600)
return make_jwt(claims)
def use_jwt_auth(monkeypatch, claims:dict) -> str:
monkeypatch.setattr(authmod, "JWT_SECRET", "test-jwt-secret")
return jwt(claims)
def test_health():
response = client.get("/health")
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert "vault" in body
assert body["cli"] == "python"
assert body["jwt"] is True
def test_command_requires_auth():
response = client.post("/commands", json={"args": ["--version"]})
assert response.status_code == 401
def test_command_runs_configured_cli_only():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"args": ["--version"], "timeout": 5},
)
assert response.status_code == 200
body = response.json()
assert body["exit_code"] == 0
assert body["command"] == ["python", "--version"]
assert "Python" in body["stdout"]
assert body["timed_out"] is False
def test_plain_command_string_is_rejected():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"command": "--version"},
)
assert response.status_code == 400
assert response.json()["detail"] == "command strings are not allowed; use args as a list of strings"
def test_shell_mode_is_rejected():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"mode": "shell", "args": ["echo nope"]},
)
assert response.status_code == 400
assert response.json()["detail"] == "mode is not allowed; only the configured Obsidian CLI can be executed"
def test_command_timeout():
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {jwt()}"},
json={"args": ["-c", "import time; time.sleep(2)"], "timeout": 0.1},
)
assert response.status_code == 200
body = response.json()
assert body["exit_code"] == 124
assert body["timed_out"] is True
def test_jwt_claims_become_policy():
token = jwt({"sub": "file", "vaults": ["work"], "paths": ["Inbox/"], "commands": ["read"]})
claims = authmod.decode_hs256_jwt(token, b"test-jwt-secret")
policy = authmod.policy_from_jwt_claims(token, claims)
assert policy.token == token
assert policy.name == "file"
assert policy.vaults == ("work",)
assert policy.paths == ("Inbox/",)
assert policy.commands == ("read",)
def test_token_policy_restricts_command(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "limited", "commands": ["read"], "vaults": ["*"], "paths": ["*"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["create", "path=Inbox/Nope.md", "content=Nope"]},
)
assert response.status_code == 403
assert response.json()["detail"] == "token is not allowed to run command: create"
def test_token_policy_restricts_vault(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "work", "commands": ["*"], "vaults": ["work"], "paths": ["*"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=personal", "read", "path=Inbox/Test.md"]},
)
assert response.status_code == 403
assert response.json()["detail"] == "token is not allowed to access vault: personal"
def test_token_policy_restricts_path(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "inbox", "commands": ["read"], "vaults": ["*"], "paths": ["Inbox/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["read", "path=Private/Secret.md"]},
)
assert response.status_code == 403
assert response.json()["detail"] == "token is not allowed to access path: Private/Secret.md"
def test_token_policy_allows_allowed_path(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "inbox", "commands": ["read"], "vaults": ["*"], "paths": ["Inbox/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["-c", "print('ok')"]},
)
assert response.status_code == 403
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["read", "path=Inbox/Allowed.md"]},
)
# The authorization layer allows this. The fake Python CLI then fails because
# "read" is not a Python flag, which is fine for this policy test.
assert response.status_code == 200
assert response.json()["command"] == ["python", "read", "path=Inbox/Allowed.md"]