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"]