From 1fac6723f12cedb133cf954fa25cb09e3ac377f4 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 4 Oct 2026 09:08:01 -0400 Subject: [PATCH 1/3] fix: forward all API auth settings to handler --- src/envault/serve.py | 44 ++++-- tests/test_serve_auth_forwarding.py | 229 ++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+), 12 deletions(-) create mode 100644 tests/test_serve_auth_forwarding.py diff --git a/src/envault/serve.py b/src/envault/serve.py index fe47665..a9d16bb 100644 --- a/src/envault/serve.py +++ b/src/envault/serve.py @@ -7,11 +7,10 @@ Security: - Default bind address is 127.0.0.1 (localhost only). - - If --api-key is provided, all endpoints (except /health) require - an Authorization: Bearer header. Requests without a - valid token receive 401 Unauthorized. - - If --api-key is not provided, a warning is printed at startup - recommending authentication for production use. + - When API credentials are configured, secret endpoints require credentials + accepted by the selected auth mode. /health and /auth/info remain public. + - Configure authentication using ENVAULT_API_KEY, ENVAULT_API_TOKEN, or an + OAuth2 endpoint. Without credentials, binding is limited to localhost. """ from __future__ import annotations @@ -487,10 +486,20 @@ def run_server( store_name : str | None Named store from config to use; if *None* the default store is used. api_key : str | None - Bearer token for API authentication. If provided, all /secrets - endpoints require an Authorization: Bearer header. - If *None*, the ENVAULT_API_KEY env var is checked; if that is also - unset, auth is disabled (with a warning). + Credential for X-API-Key authentication. If *None*, read ENVAULT_API_KEY. + The selected auth mode determines which header is accepted. + api_token : str | None + Static Bearer token. If *None*, read ENVAULT_API_TOKEN. + oauth_introspect_url : str | None + OAuth2 introspection endpoint, or read ENVAULT_OAUTH_INTROSPECT_URL. + oauth_userinfo_url : str | None + OAuth2 userinfo endpoint, or read ENVAULT_OAUTH_USERINFO_URL. + oauth_client_id : str | None + OAuth2 client ID, or read ENVAULT_OAUTH_CLIENT_ID. + oauth_client_secret : str | None + OAuth2 client secret, or read ENVAULT_OAUTH_CLIENT_SECRET. + auth_mode : str + Credential mode: bearer, api-key, oauth2, or any. """ # Resolve encryption key (same auth model as decrypt command) @@ -555,7 +564,18 @@ def run_server( else: store_instance = get_store("") - handler_class = create_handler(store_instance, config, encrypt_key, resolved_api_key) + handler_class = create_handler( + store=store_instance, + config=config, + encrypt_key=encrypt_key, + api_key=resolved_api_key, + api_token=api_token, + oauth_introspect_url=oauth_introspect_url, + oauth_userinfo_url=oauth_userinfo_url, + oauth_client_id=oauth_client_id, + oauth_client_secret=oauth_client_secret, + auth_mode=auth_mode, + ) server = HTTPServer((host, port), handler_class) from rich.console import Console @@ -566,8 +586,8 @@ def run_server( console.print(" GET /secrets?prefix=X — filter keys by prefix") console.print(" GET /secrets/{key} — get decrypted value") console.print(" GET /health — store connectivity check") - if resolved_api_key: - console.print("[green]🔒[/green] API authentication enabled (Bearer token required)") + if has_any_auth: + console.print("[green]🔒[/green] API authentication enabled") else: console.print("[yellow]⚠[/yellow] No API key set — secrets endpoints are unauthenticated!") console.print("[dim] Set --api-key flag or ENVAULT_API_KEY env var to enable auth[/dim]") diff --git a/tests/test_serve_auth_forwarding.py b/tests/test_serve_auth_forwarding.py new file mode 100644 index 0000000..c7c61ff --- /dev/null +++ b/tests/test_serve_auth_forwarding.py @@ -0,0 +1,229 @@ +"""Regression coverage for authentication passed from run_server to handlers.""" + +from __future__ import annotations + +import pytest + +import envault.serve as serve_module +from envault.config import EnvaultConfig +from envault.serve import SecretHandler + +AUTH_ENVIRONMENTS = ( + "ENVAULT_API_KEY", + "ENVAULT_API_TOKEN", + "ENVAULT_OAUTH_INTROSPECT_URL", + "ENVAULT_OAUTH_USERINFO_URL", + "ENVAULT_OAUTH_CLIENT_ID", + "ENVAULT_OAUTH_CLIENT_SECRET", +) + + +class _MemoryStore: + def list_keys(self, prefix: str = "") -> list[str]: + keys = ["SAMPLE"] + return [key for key in keys if key.startswith(prefix)] + + def get(self, key: str) -> str | None: + return "example" if key == "SAMPLE" else None + + +class _NonBindingServer: + """Capture the handler factory without opening a listening socket.""" + + instances = [] + + def __init__(self, address, handler_class): + self.address = address + self.handler_class = handler_class + self.instances.append(self) + + def serve_forever(self) -> None: + return None + + def server_close(self) -> None: + return None + + +def _start_with_dummy_environment(monkeypatch, *, auth_mode: str = "any", **environment: str): + _NonBindingServer.instances.clear() + for name in AUTH_ENVIRONMENTS: + monkeypatch.delenv(name, raising=False) + for name, value in environment.items(): + monkeypatch.setenv(name, value) + + monkeypatch.setattr(serve_module, "HTTPServer", _NonBindingServer) + monkeypatch.setattr(serve_module, "get_store", lambda _config: _MemoryStore()) + serve_module.run_server( + config=EnvaultConfig(), + host="0.0.0.0", + encrypt_key="fixture-encryption-key", + auth_mode=auth_mode, + ) + assert len(_NonBindingServer.instances) == 1 + return _NonBindingServer.instances[0].handler_class + + +def _request(handler_class, path: str, headers: dict[str, str]): + """Create an in-memory request handler with no server or network I/O.""" + instance = object.__new__(handler_class) + instance.command = "GET" + instance.path = path + instance.headers = headers + instance._sent_json = None + instance._sent_status = None + + def capture_json(payload, status=200): + instance._sent_json = payload + instance._sent_status = status + + instance._send_json = capture_json + instance._send_error = lambda status, message: capture_json({"error": message}, status) + return instance + + +def test_token_only_environment_requires_a_valid_bearer_token(monkeypatch, capsys): + token = "fixture-token-314" + handler_class = _start_with_dummy_environment( + monkeypatch, + ENVAULT_API_TOKEN=token, + ) + + assert handler_class.api_token == token + assert handler_class.api_key is None + assert handler_class.auth_mode == "any" + startup_output = capsys.readouterr().out + assert "API authentication enabled" in startup_output + assert token not in startup_output + + auth_info = _request(handler_class, "/auth/info", {}) + auth_info.do_GET() + assert auth_info._sent_json["requires_auth"] is True + assert auth_info._sent_json["methods"] == ["bearer"] + assert token not in str(auth_info._sent_json) + + unauthenticated = _request(handler_class, "/secrets", {}) + unauthenticated.do_GET() + assert unauthenticated._sent_status == 401 + + wrong = _request(handler_class, "/secrets", {"Authorization": "Bearer fixture-token-271"}) + wrong.do_GET() + assert wrong._sent_status == 401 + + authenticated = _request(handler_class, "/secrets", {"Authorization": f"Bearer {token}"}) + authenticated.do_GET() + assert authenticated._sent_status == 200 + assert authenticated._sent_json["keys"] == ["SAMPLE"] + + +def test_api_key_environment_remains_forwarded(monkeypatch): + api_key = "fixture-api-key-314" + handler_class = _start_with_dummy_environment( + monkeypatch, + ENVAULT_API_KEY=api_key, + auth_mode="api-key", + ) + + assert handler_class.api_key == api_key + assert handler_class.api_token is None + + unauthenticated = _request(handler_class, "/secrets", {}) + unauthenticated.do_GET() + assert unauthenticated._sent_status == 401 + + authenticated = _request(handler_class, "/secrets", {"X-API-Key": api_key}) + authenticated.do_GET() + assert authenticated._sent_status == 200 + assert authenticated._sent_json["keys"] == ["SAMPLE"] + + +@pytest.mark.parametrize( + ("endpoint_environment", "handler_attribute", "auth_method", "validator"), + [ + ( + "ENVAULT_OAUTH_INTROSPECT_URL", + "oauth_introspect_url", + "oauth2-introspect", + "_oauth2_introspect", + ), + ( + "ENVAULT_OAUTH_USERINFO_URL", + "oauth_userinfo_url", + "oauth2-userinfo", + "_oauth2_userinfo", + ), + ], +) +def test_oauth_environment_requires_a_validated_bearer_token( + monkeypatch, endpoint_environment, handler_attribute, auth_method, validator +): + endpoint = "https://identity.invalid/oauth" + client_id = "fixture-client-271" + client_secret = "fixture-client-secret-314" + + def validate_dummy_token(handler, value): + if value == "fixture-oauth-token-314": + return True + handler._send_error(401, "Unauthorized: dummy token rejected") + return False + + monkeypatch.setattr(serve_module, "_oauth2_cache", {}) + monkeypatch.setattr( + SecretHandler, + validator, + validate_dummy_token, + ) + handler_class = _start_with_dummy_environment( + monkeypatch, + **{ + endpoint_environment: endpoint, + "ENVAULT_OAUTH_CLIENT_ID": client_id, + "ENVAULT_OAUTH_CLIENT_SECRET": client_secret, + }, + auth_mode="oauth2", + ) + + assert getattr(handler_class, handler_attribute) == endpoint + assert handler_class.oauth_client_id == client_id + assert handler_class.oauth_client_secret == client_secret + assert handler_class.auth_mode == "oauth2" + + auth_info = _request(handler_class, "/auth/info", {}) + auth_info.do_GET() + assert auth_info._sent_json["requires_auth"] is True + assert auth_info._sent_json["methods"] == [auth_method] + assert client_secret not in str(auth_info._sent_json) + + unauthenticated = _request(handler_class, "/secrets", {}) + unauthenticated.do_GET() + assert unauthenticated._sent_status == 401 + + invalid = _request(handler_class, "/secrets", {"Authorization": "Bearer invalid-fixture-token"}) + invalid.do_GET() + assert invalid._sent_status == 401 + + authenticated = _request( + handler_class, + "/secrets", + {"Authorization": "Bearer fixture-oauth-token-314"}, + ) + authenticated.do_GET() + assert authenticated._sent_status == 200 + assert authenticated._sent_json["keys"] == ["SAMPLE"] + + +def test_non_localhost_binding_still_rejects_missing_credentials(monkeypatch): + for name in AUTH_ENVIRONMENTS: + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr( + serve_module, + "HTTPServer", + lambda *_args: pytest.fail("An unauthenticated server must not be constructed"), + ) + monkeypatch.setattr(serve_module, "get_store", lambda _config: pytest.fail("The store must not be read")) + + with pytest.raises(SystemExit, match="API authentication required"): + serve_module.run_server( + config=EnvaultConfig(), + host="0.0.0.0", + encrypt_key="fixture-encryption-key", + ) From 2a963ce4344cb1d59a0ae5ee0cbeab9da7bac1ff Mon Sep 17 00:00:00 2001 From: Jaixii Date: Tue, 6 Oct 2026 02:11:05 -0400 Subject: [PATCH 2/3] fix: reject empty bearer tokens and forward CLI auth --- README.md | 23 ++-- src/envault/cli.py | 6 +- src/envault/serve.py | 5 +- tests/test_serve_auth_hardening.py | 211 +++++++++++++++++++++++++++++ 4 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 tests/test_serve_auth_hardening.py diff --git a/README.md b/README.md index 8b7a69d..e26d093 100644 --- a/README.md +++ b/README.md @@ -116,25 +116,26 @@ Expose decrypted secrets as an HTTP JSON API — perfect for MCP servers, CI/CD rh-envault serve --port 8080 # Custom host and password -rh-envault serve --host 0.0.0.0 --port 3000 --password your-master-key +rh-envault serve --host 0.0.0.0 --port 3000 --password your-master-key --api-token my-token ``` Endpoints: - `GET /health` — store connectivity check (no auth required) +- `GET /auth/info` — discover configured authentication methods (no auth required) - `GET /secrets` — list all secret keys, optional `?prefix=X` filter (auth required) - `GET /secrets/{key}` — get decrypted value for a key (auth required) -Authentication: Bearer token in the `Authorization` header. The token is the SHA-256 hex digest of your encryption key. +Authentication: set `--api-token` or `ENVAULT_API_TOKEN` for `Authorization: Bearer `, or set `--api-key` or `ENVAULT_API_KEY` for `X-API-Key: `. API credentials are separate from the encryption password. Without credentials, secrets routes are unauthenticated and binding is limited to localhost. ```bash # List all secrets -curl -H "Authorization: Bearer " http://localhost:8080/secrets +curl -H "Authorization: Bearer my-token" http://localhost:8080/secrets # Filter by prefix -curl -H "Authorization: Bearer " "http://localhost:8080/secrets?prefix=STRIPE" +curl -H "Authorization: Bearer my-token" "http://localhost:8080/secrets?prefix=STRIPE" # Get a specific secret -curl -H "Authorization: Bearer " http://localhost:8080/secrets/DB_PASSWORD +curl -H "Authorization: Bearer my-token" http://localhost:8080/secrets/DB_PASSWORD ``` ### `rh-envault audit` @@ -156,7 +157,7 @@ Start an HTTP server that exposes decrypted secrets as a JSON API — ideal for rh-envault serve # Custom port, host, and API key -rh-envault serve --port 3000 --host 0.0.0.0 --api-key my-bearer-token +rh-envault serve --port 3000 --host 0.0.0.0 --api-key my-api-key # Use a named store from config rh-envault serve --store production-secrets @@ -167,23 +168,25 @@ rh-envault serve --store production-secrets | Endpoint | Auth | Description | |----------|------|-------------| | `GET /health` | No | Store connectivity check | +| `GET /auth/info` | No | Configured authentication methods | | `GET /secrets` | Yes | List all secret keys (filter with `?prefix=X`) | | `GET /secrets/{key}` | Yes | Get decrypted value for a key | **Security:** - Defaults to `127.0.0.1` (localhost only) — use `--host 0.0.0.0` only behind a firewall or reverse proxy -- Set `--api-key` or `ENVAULT_API_KEY` env var to require Bearer token auth on `/secrets` endpoints +- Set `--api-key` or `ENVAULT_API_KEY` for `X-API-Key` authentication, or `--api-token` or `ENVAULT_API_TOKEN` for Bearer authentication on `/secrets` endpoints +- Without API credentials, secrets endpoints are unauthenticated and binding is limited to localhost - No built-in TLS — run behind a reverse proxy (nginx, Caddy) for HTTPS in production ```bash # Fetch secrets with curl -curl -H "Authorization: Bearer my-token" http://localhost:8080/secrets +curl -H "X-API-Key: my-api-key" http://localhost:8080/secrets # Filter by prefix -curl -H "Authorization: Bearer my-token" "http://localhost:8080/secrets?prefix=STRIPE" +curl -H "X-API-Key: my-api-key" "http://localhost:8080/secrets?prefix=STRIPE" # Get a specific secret -curl -H "Authorization: Bearer my-token" http://localhost:8080/secrets/DB_PASSWORD +curl -H "X-API-Key: my-api-key" http://localhost:8080/secrets/DB_PASSWORD ``` ## Features diff --git a/src/envault/cli.py b/src/envault/cli.py index 2aa2357..6641ee4 100644 --- a/src/envault/cli.py +++ b/src/envault/cli.py @@ -736,7 +736,7 @@ def serve( "-k", help="Encryption password (prompted if omitted, or use ENVAULT_ENCRYPT_KEY)", ), - api_key: str | None = typer.Option(None, "--api-key", help="Bearer token for API auth (or set ENVAULT_API_KEY)"), + api_key: str | None = typer.Option(None, "--api-key", help="X-API-Key header credential (or set ENVAULT_API_KEY)"), store: str | None = typer.Option(None, "--store", "-s", help="Named store from config to use"), config_path: str = typer.Option("", "--config", "-c", help="Config file path"), api_token: str | None = typer.Option( @@ -760,7 +760,8 @@ def serve( Security: - Default bind is 127.0.0.1 (localhost only); use --host 0.0.0.0 to expose. - - Set --api-key or ENVAULT_API_KEY to require Bearer token auth on /secrets. + - Set --api-key or ENVAULT_API_KEY for X-API-Key header authentication. + - Set --api-token or ENVAULT_API_TOKEN for Authorization: Bearer authentication. """ config = load_config(config_path) run_server( @@ -770,6 +771,7 @@ def serve( encrypt_key=password, store_name=store, api_key=api_key, + api_token=api_token, ) diff --git a/src/envault/serve.py b/src/envault/serve.py index a9d16bb..8d922fb 100644 --- a/src/envault/serve.py +++ b/src/envault/serve.py @@ -81,6 +81,9 @@ def _check_bearer_token(self) -> bool: return False token = auth_header[len("Bearer ") :] + if not token: + self._send_error(401, "Unauthorized: Bearer token required") + return False # If OAuth2 introspection URL is configured, validate via introspection if self.oauth_introspect_url: @@ -91,7 +94,7 @@ def _check_bearer_token(self) -> bool: return self._oauth2_userinfo(token) # Otherwise, fall back to static token check - if token != (self.api_token or ""): + if not self.api_token or token != self.api_token: self._send_error(401, "Unauthorized: invalid Bearer token") return False diff --git a/tests/test_serve_auth_hardening.py b/tests/test_serve_auth_hardening.py new file mode 100644 index 0000000..d58cad6 --- /dev/null +++ b/tests/test_serve_auth_hardening.py @@ -0,0 +1,211 @@ +"""Dummy-only regressions for default auth and the CLI-to-server boundary.""" + +from __future__ import annotations + +import re + +import pytest +from typer.testing import CliRunner + +import envault.cli as cli_module +import envault.serve as serve_module +from envault.config import EnvaultConfig + +AUTH_ENVIRONMENTS = ( + "ENVAULT_API_KEY", + "ENVAULT_API_TOKEN", + "ENVAULT_OAUTH_INTROSPECT_URL", + "ENVAULT_OAUTH_USERINFO_URL", + "ENVAULT_OAUTH_CLIENT_ID", + "ENVAULT_OAUTH_CLIENT_SECRET", +) +SECRET_PATHS = ("/secrets", "/secrets/SAMPLE") + + +class _MemoryStore: + def list_keys(self, prefix=""): + return [key for key in ["SAMPLE"] if key.startswith(prefix)] + + def get(self, key): + return "fixture-value" if key == "SAMPLE" else None + + +@pytest.fixture +def servers(monkeypatch): + """Isolate credentials and capture server construction without sockets.""" + instances = [] + + class NonBindingServer: + def __init__(self, address, handler_class): + self.address = address + self.handler_class = handler_class + instances.append(self) + + def serve_forever(self): + pass + + def server_close(self): + pass + + for name in AUTH_ENVIRONMENTS: + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(serve_module, "HTTPServer", NonBindingServer) + monkeypatch.setattr(serve_module, "get_store", lambda _config: _MemoryStore()) + monkeypatch.setattr(cli_module, "load_config", lambda _path: EnvaultConfig()) + return instances + + +def _request(handler_class, path, headers): + handler = object.__new__(handler_class) + handler.path = path + handler.headers = headers + response = {} + handler._send_json = lambda payload, status=200: response.update(status=status, payload=payload) + handler._send_error = lambda status, message: response.update(status=status, payload={"error": message}) + handler.do_GET() + return response + + +def _assert_authenticated(response, path): + assert response["status"] == 200 + if path == "/secrets": + assert response["payload"]["keys"] == ["SAMPLE"] + else: + assert response["payload"] == {"key": "SAMPLE", "value": "fixture-value"} + + +@pytest.mark.parametrize("path", SECRET_PATHS) +@pytest.mark.parametrize("auth_mode", ["any", "api-key"]) +def test_api_key_only_rejects_bearer_bypass(servers, path, auth_mode): + serve_module.run_server( + EnvaultConfig(), + host="0.0.0.0", + encrypt_key="fixture-encryption-key", + api_key="fixture-api-key", + auth_mode=auth_mode, + ) + handler_class = servers[0].handler_class + for headers in ( + {}, + {"Authorization": "Basic fixture"}, + {"Authorization": "Bearer "}, + {"Authorization": "Bearer incorrect"}, + {"X-API-Key": "incorrect"}, + ): + assert _request(handler_class, path, headers)["status"] == 401 + _assert_authenticated(_request(handler_class, path, {"X-API-Key": "fixture-api-key"}), path) + + +@pytest.mark.parametrize("path", SECRET_PATHS) +def test_bearer_mode_without_static_token_rejects_empty_token(servers, path): + serve_module.run_server( + EnvaultConfig(), + host="0.0.0.0", + encrypt_key="fixture-encryption-key", + api_key="fixture-api-key", + auth_mode="bearer", + ) + handler_class = servers[0].handler_class + assert _request(handler_class, path, {"Authorization": "Bearer "})["status"] == 401 + assert _request(handler_class, path, {"X-API-Key": "fixture-api-key"})["status"] == 401 + + +@pytest.mark.parametrize("path", SECRET_PATHS) +@pytest.mark.parametrize("auth_mode", ["any", "bearer"]) +def test_static_token_validation_is_preserved(servers, path, auth_mode): + serve_module.run_server( + EnvaultConfig(), + host="0.0.0.0", + encrypt_key="fixture-encryption-key", + api_token="fixture-static-token", + auth_mode=auth_mode, + ) + handler_class = servers[0].handler_class + for headers in ({}, {"Authorization": "Bearer "}, {"Authorization": "Bearer incorrect"}): + assert _request(handler_class, path, headers)["status"] == 401 + _assert_authenticated(_request(handler_class, path, {"Authorization": "Bearer fixture-static-token"}), path) + + +@pytest.mark.parametrize("path", SECRET_PATHS) +@pytest.mark.parametrize("auth_mode", ["any", "bearer", "oauth2"]) +@pytest.mark.parametrize( + ("endpoint", "validator"), + [("oauth_introspect_url", "_oauth2_introspect"), ("oauth_userinfo_url", "_oauth2_userinfo")], +) +def test_oauth_validation_is_preserved_without_provider_calls( + servers, monkeypatch, path, auth_mode, endpoint, validator +): + calls = [] + + def validate(handler, token): + calls.append(token) + if token == "fixture-oauth-token": + return True + handler._send_error(401, "Unauthorized: fixture token rejected") + return False + + monkeypatch.setattr(serve_module.SecretHandler, validator, validate) + serve_module.run_server( + EnvaultConfig(), + host="0.0.0.0", + encrypt_key="fixture-encryption-key", + api_token="fixture-static-token", + auth_mode=auth_mode, + **{endpoint: "https://identity.invalid/oauth"}, + ) + handler_class = servers[0].handler_class + for headers in ({}, {"Authorization": "Bearer "}): + assert _request(handler_class, path, headers)["status"] == 401 + assert calls == [] + for token in ("incorrect", "fixture-static-token"): + assert _request(handler_class, path, {"Authorization": f"Bearer {token}"})["status"] == 401 + _assert_authenticated(_request(handler_class, path, {"Authorization": "Bearer fixture-oauth-token"}), path) + assert calls == ["incorrect", "fixture-static-token", "fixture-oauth-token"] + + +@pytest.mark.parametrize("path", SECRET_PATHS) +@pytest.mark.parametrize("host", ["127.0.0.1", "0.0.0.0"]) +def test_cli_api_token_overrides_environment_and_protects_secret_routes(servers, monkeypatch, path, host): + monkeypatch.setenv("ENVAULT_API_TOKEN", "fixture-environment-token") + result = CliRunner().invoke( + cli_module.app, + ["serve", "--host", host, "--password", "fixture-encryption-key", "--api-token", "fixture-cli-token"], + ) + assert result.exit_code == 0, result.output + assert len(servers) == 1 + assert servers[0].address == (host, 8080) + handler_class = servers[0].handler_class + assert handler_class.api_token == "fixture-cli-token" + for headers in ( + {}, + {"Authorization": "Bearer "}, + {"Authorization": "Bearer incorrect"}, + {"Authorization": "Bearer fixture-environment-token"}, + ): + assert _request(handler_class, path, headers)["status"] == 401 + _assert_authenticated(_request(handler_class, path, {"Authorization": "Bearer fixture-cli-token"}), path) + assert "fixture-cli-token" not in result.output + + +@pytest.mark.parametrize("host", ["127.0.0.1", "0.0.0.0"]) +def test_cli_api_token_works_without_auth_environment(servers, host): + result = CliRunner().invoke( + cli_module.app, + ["serve", "--host", host, "--password", "fixture-encryption-key", "--api-token", "fixture-cli-token"], + ) + assert result.exit_code == 0, result.output + assert len(servers) == 1 + assert servers[0].handler_class.api_token == "fixture-cli-token" + + +def test_cli_external_binding_without_credentials_still_fails_closed(servers): + result = CliRunner().invoke(cli_module.app, ["serve", "--host", "0.0.0.0", "--password", "fixture-encryption-key"]) + assert result.exit_code != 0 + assert "API authentication required" in result.output + assert servers == [] + + +def test_cli_api_key_help_names_the_correct_header(): + result = CliRunner().invoke(cli_module.app, ["serve", "--help"], env={"COLUMNS": "200"}) + assert result.exit_code == 0 + assert "X-API-Key" in re.sub(r"\x1b\[[0-9;]*m", "", result.output) From 5384703c9acd3f0c32fefe00e4d1cee56a0bca8b Mon Sep 17 00:00:00 2001 From: Jaixii Date: Tue, 6 Oct 2026 11:44:32 -0400 Subject: [PATCH 3/3] style: clear existing auth and store test format warnings --- tests/test_auth_coverage.py | 1 + tests/test_history.py | 2 +- tests/test_stores_integration.py | 19 ++++++++++--------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/test_auth_coverage.py b/tests/test_auth_coverage.py index e233ceb..beb8167 100644 --- a/tests/test_auth_coverage.py +++ b/tests/test_auth_coverage.py @@ -4,6 +4,7 @@ cache expiry, scope/audience validation, error paths), MultiAuth fallback logic, and build_auth_from_env factory. """ + from __future__ import annotations import json diff --git a/tests/test_history.py b/tests/test_history.py index 4cd0ebb..3c5c0ee 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -29,7 +29,7 @@ def test_parse_env_content_basic(): def test_parse_env_content_strips_symmetric_quotes(): - content = 'A="quoted"\nB=' + "'single'\n" + "C=un\"matched\n" + content = 'A="quoted"\nB=' + "'single'\n" + 'C=un"matched\n' parsed = _parse_env_content(content) assert parsed["A"] == "quoted" assert parsed["B"] == "single" diff --git a/tests/test_stores_integration.py b/tests/test_stores_integration.py index 95cebdd..df351fb 100644 --- a/tests/test_stores_integration.py +++ b/tests/test_stores_integration.py @@ -255,8 +255,9 @@ def test_get_raises_on_real_error(self): mock_client = MagicMock() mock_client.secrets.kv.v2.read_secret.side_effect = Exception("permission denied") - with patch.object(store, "_get_client", return_value=mock_client), pytest.raises( - SecretStoreError, match="Vault read failed" + with ( + patch.object(store, "_get_client", return_value=mock_client), + pytest.raises(SecretStoreError, match="Vault read failed"), ): store.get("MY_KEY") @@ -265,12 +266,11 @@ def test_delete_raises_on_real_error(self): store = VaultStore(token="s.test") mock_client = MagicMock() - mock_client.secrets.kv.v2.delete_metadata_and_all_versions.side_effect = Exception( - "connection refused" - ) + mock_client.secrets.kv.v2.delete_metadata_and_all_versions.side_effect = Exception("connection refused") - with patch.object(store, "_get_client", return_value=mock_client), pytest.raises( - SecretStoreError, match="Vault delete failed" + with ( + patch.object(store, "_get_client", return_value=mock_client), + pytest.raises(SecretStoreError, match="Vault delete failed"), ): store.delete("OLD_KEY") @@ -281,8 +281,9 @@ def test_list_raises_on_real_error(self): mock_client = MagicMock() mock_client.secrets.kv.v2.list_secrets.side_effect = Exception("500 internal server error") - with patch.object(store, "_get_client", return_value=mock_client), pytest.raises( - SecretStoreError, match="Vault list failed" + with ( + patch.object(store, "_get_client", return_value=mock_client), + pytest.raises(SecretStoreError, match="Vault list failed"), ): store.list_keys()