feat(properties): prepare property commands before execution
Build and Push Docker Container / build-and-push (push) Successful in 4m28s

- Create missing note files before running property:set.
- Default date-only datetime values to midnight.
- Keep property names out of path authorization checks.
- Split policy, vault, daily note, and command target helpers.
- Split n8n node args, description, output, and API response helpers.
- Split API tests by auth, command, daily note, and property behavior.
- Bump API and n8n node versions.
This commit is contained in:
2026-06-24 14:29:48 +02:00
parent e8e31add11
commit c34dab2cca
25 changed files with 1099 additions and 1018 deletions
+46
View File
@@ -0,0 +1,46 @@
import base64
import hashlib
import hmac
import json
import os
import time
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 use_work_vault(monkeypatch, vault) -> None:
monkeypatch.setattr("app.vaults.REGISTERED_VAULTS", {"work": str(vault)})
monkeypatch.setattr("app.vaults.DEFAULT_VAULT_NAME", "work")
monkeypatch.setattr("app.policy.DEFAULT_VAULT_NAME", "work")
-263
View File
@@ -1,263 +0,0 @@
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
assert response.json()["detail"] == "missing Authorization header; expected 'Bearer <jwt>'"
def test_invalid_token_reports_reason(monkeypatch):
monkeypatch.setattr("app.auth.JWT_SECRET", "secret")
response = client.post(
"/commands",
headers={"Authorization": "Bearer not-a-jwt"},
json={"args": ["--version"]},
)
assert response.status_code == 401
assert response.json()["detail"].startswith("invalid token:")
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"].startswith("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"].startswith("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"].startswith("token is not allowed to access path: Private/Secret.md")
def test_property_set_name_is_not_treated_as_path(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["property:set"], "vaults": ["*"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["property:set", "path=00. Journal/Note.md", "name=from", "value=Daniel", "type=text"]},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "property:set", "path=00. Journal/Note.md", "name=from", "value=Daniel", "type=text"]
def test_rename_name_is_treated_as_path(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["rename"], "vaults": ["*"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["rename", "path=00. Journal/Old.md", "name=Private/New.md"]},
)
assert response.status_code == 403
assert response.json()["detail"].startswith("token is not allowed to access path: Private/New.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_allows_vault_info_command(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append", "vault", "vaults"], "vaults": ["*"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault"]},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vault"]
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vaults"]},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vaults"]
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/")
+87
View File
@@ -0,0 +1,87 @@
import app.auth as authmod
from conftest import client, jwt, use_jwt_auth
def test_command_requires_auth():
response = client.post("/commands", json={"args": ["--version"]})
assert response.status_code == 401
assert response.json()["detail"] == "missing Authorization header; expected 'Bearer <jwt>'"
def test_invalid_token_reports_reason(monkeypatch):
monkeypatch.setattr("app.auth.JWT_SECRET", "secret")
response = client.post(
"/commands",
headers={"Authorization": "Bearer not-a-jwt"},
json={"args": ["--version"]},
)
assert response.status_code == 401
assert response.json()["detail"].startswith("invalid token:")
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"].startswith("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"].startswith("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"].startswith("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"]},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "read", "path=Inbox/Allowed.md"]
+57
View File
@@ -0,0 +1,57 @@
from conftest import client, jwt
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_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
+60
View File
@@ -0,0 +1,60 @@
import json
from conftest import client, use_jwt_auth, use_work_vault
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"}))
use_work_vault(monkeypatch, vault)
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_allows_vault_info_command(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append", "vault", "vaults"], "vaults": ["*"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault"]},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vault"]
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vaults"]},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vaults"]
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"}))
use_work_vault(monkeypatch, vault)
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/")
+58
View File
@@ -0,0 +1,58 @@
from conftest import client, use_jwt_auth, use_work_vault
def test_property_set_name_is_not_treated_as_path(monkeypatch, tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
use_work_vault(monkeypatch, vault)
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["property:set"], "vaults": ["work"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=work", "property:set", "path=00. Journal/Note.md", "name=from", "value=Daniel", "type=text"], "cwd": str(vault)},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vault=work", "property:set", "path=00. Journal/Note.md", "name=from", "value=Daniel", "type=text"]
def test_property_set_creates_missing_file(monkeypatch, tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
use_work_vault(monkeypatch, vault)
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["property:set"], "vaults": ["work"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=work", "property:set", "path=00. Journal/New.md", "name=from", "value=Daniel", "type=text"], "cwd": str(vault)},
)
assert response.status_code == 200
assert (vault / "00. Journal" / "New.md").exists()
def test_property_set_datetime_defaults_missing_time(monkeypatch, tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
use_work_vault(monkeypatch, vault)
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["property:set"], "vaults": ["work"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=work", "property:set", "path=00. Journal/New.md", "name=from", "value=2026-06-24T", "type=datetime"], "cwd": str(vault)},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vault=work", "property:set", "path=00. Journal/New.md", "name=from", "value=2026-06-24T00:00:00", "type=datetime"]
def test_rename_name_is_treated_as_path(monkeypatch):
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["rename"], "vaults": ["*"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["rename", "path=00. Journal/Old.md", "name=Private/New.md"]},
)
assert response.status_code == 403
assert response.json()["detail"].startswith("token is not allowed to access path: Private/New.md")