Files
obsidian-api/tests/test_api.py
T
daniel156161 be78e3aa00
Build and Push Docker Container / build-and-push (push) Successful in 4m15s
feat: resolve daily-note path from Obsidian config for scoped tokens
- Add daily_note_path_for_vault to read .obsidian/daily-notes.json

- Honor configured folder and date format for daily commands

- Add moment_to_strftime to translate Obsidian date tokens

- Authorize pathless daily:* commands against the resolved path

- Pick vault root from token vault, cwd, or default vault

- Normalize n8n httpRequest response to avoid circular JSON output

- Force returnFullResponse false and parse body to plain result

- Bump n8n-nodes-obsidian-cli-api to 0.2.0

- Add tests for allowed and denied scoped daily commands
2026-06-24 11:01:57 +02:00

206 lines
7.2 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"]
def test_path_restricted_token_allows_daily_command_from_obsidian_config(monkeypatch, tmp_path):
vault = tmp_path / "vault"
config = vault / ".obsidian"
config.mkdir(parents=True)
(config / "daily-notes.json").write_text(json.dumps({"folder": "00. Journal", "format": "YYYY-MM-DD"}))
monkeypatch.setattr("app.policy.REGISTERED_VAULTS", {"work": str(vault)})
monkeypatch.setattr("app.policy.DEFAULT_VAULT_NAME", "work")
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append"], "vaults": ["work"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=work", "daily:append", "content=ok"], "cwd": str(vault)},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vault=work", "daily:append", "content=ok"]
def test_path_restricted_token_denies_daily_command_outside_obsidian_config(monkeypatch, tmp_path):
vault = tmp_path / "vault"
config = vault / ".obsidian"
config.mkdir(parents=True)
(config / "daily-notes.json").write_text(json.dumps({"folder": "Journal", "format": "YYYY-MM-DD"}))
monkeypatch.setattr("app.policy.REGISTERED_VAULTS", {"work": str(vault)})
monkeypatch.setattr("app.policy.DEFAULT_VAULT_NAME", "work")
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append"], "vaults": ["work"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=work", "daily:append", "content=ok"], "cwd": str(vault)},
)
assert response.status_code == 403
assert response.json()["detail"].startswith("token is not allowed to access path: Journal/")