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 '" 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/")