be78e3aa00
Build and Push Docker Container / build-and-push (push) Successful in 4m15s
- 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
206 lines
7.2 KiB
Python
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/")
|