Skip to content

Commit 0ff6fa9

Browse files
committed
add tests for secrets manager
1 parent 0549c23 commit 0ff6fa9

File tree

5 files changed

+243
-29
lines changed

5 files changed

+243
-29
lines changed

conftest.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1+
from __future__ import annotations
2+
import asyncio
13
from pathlib import Path
2-
34
import pytest
5+
from traitlets.config import Config, LoggingConfigurable
6+
import logging
7+
from jupyter_server.services.contents.filemanager import AsyncFileContentsManager
8+
from typing import TYPE_CHECKING
49

5-
pytest_plugins = ("jupyter_server.pytest_plugin",)
10+
if TYPE_CHECKING:
11+
from jupyter_server.serverapp import ServerApp
612

13+
pytest_plugins = ("jupyter_server.pytest_plugin",)
714

815
@pytest.fixture
9-
def jp_server_config(jp_server_config):
10-
return {"ServerApp": {"jpserver_extensions": {"jupyter_ai": True}}}
16+
def jp_server_config(jp_server_config, tmp_path):
17+
return Config({"ServerApp": {"jpserver_extensions": {"jupyter_ai": True}}, "ContentsManager": {"root_dir": str(tmp_path)}})
1118

1219

1320
@pytest.fixture(scope="session")
@@ -21,9 +28,45 @@ def static_test_files_dir() -> Path:
2128
/ "static"
2229
)
2330

31+
class MockAiExtension(LoggingConfigurable):
32+
"""Mock AiExtension class for testing purposes."""
33+
34+
serverapp: ServerApp
35+
36+
def __init__(self, *args, serverapp: ServerApp, **kwargs):
37+
super().__init__(*args, **kwargs)
38+
self.serverapp = serverapp
39+
self._log = None
40+
41+
@property
42+
def log(self) -> logging.Logger:
43+
return self.serverapp.log
44+
45+
@property
46+
def event_loop(self) -> asyncio.AbstractEventLoop:
47+
return self.serverapp.io_loop.asyncio_loop
48+
49+
@property
50+
def contents_manager(self) -> AsyncFileContentsManager:
51+
return self.serverapp.contents_manager
52+
2453

2554
@pytest.fixture
26-
def jp_ai_staging_dir(jp_data_dir: Path) -> Path:
27-
staging_area = jp_data_dir / "scheduler_staging_area"
28-
staging_area.mkdir()
29-
return staging_area
55+
def mock_ai_extension(jp_server_config, jp_configurable_serverapp) -> MockAiExtension:
56+
"""
57+
Returns a mocked `AiExtension` object that can be passed as the `parent`
58+
argument to objects normally initialized by `AiExtension`. This should be
59+
passed to most of the "manager singletons" like `ConfigManager`,
60+
`PersonaManager`, and `EnvSecretsManager`.
61+
62+
See `MockAiExtension` in `conftest.py` for a complete description of the
63+
attributes, properties, and methods available. If something is missing,
64+
please feel free to add to it in your PR.
65+
66+
Returns:
67+
A `MockAiExtension` instance that can be passed as the `parent` argument
68+
to objects normally initialized by `AiExtension`.
69+
"""
70+
serverapp = jp_configurable_serverapp()
71+
return MockAiExtension(config=jp_server_config, serverapp=serverapp)
72+

packages/jupyter-ai/jupyter_ai/secrets/secrets_manager.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,3 +380,9 @@ def stop(self) -> None:
380380
This method should be called if and only if the server is shutting down.
381381
"""
382382
self._watch_dotenv_task.cancel()
383+
384+
# Reset any environment variables set in `.env` back to their initial
385+
# values. This is only required for the unit test suite.
386+
envvar_names = self._dotenv_env.keys()
387+
self._reset_envvars(envvar_names)
388+
self._dotenv_env = {}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""
2+
Unit tests for the `..secrets_manager` module.
3+
"""
4+
5+
from __future__ import annotations
6+
import asyncio
7+
import pytest
8+
from unittest.mock import AsyncMock, Mock, patch
9+
from typing import TYPE_CHECKING
10+
import os
11+
12+
from jupyter_ai.secrets.secrets_manager import EnvSecretsManager
13+
from jupyter_ai.secrets.secrets_types import SecretsList
14+
15+
if TYPE_CHECKING:
16+
from pathlib import Path
17+
18+
19+
@pytest.fixture
20+
def dotenv_path(tmp_path):
21+
"""
22+
Returns the expected path of the `.env` file managed by the `manager`
23+
fixture.
24+
"""
25+
return tmp_path / ".env"
26+
27+
28+
@pytest.fixture
29+
def manager(mock_ai_extension, dotenv_path):
30+
"""
31+
Returns the configured `EnvSecretsManager` instance to be tested in this
32+
file.
33+
"""
34+
# Yield configured `EnvSecretsManager`
35+
manager = EnvSecretsManager(parent=mock_ai_extension)
36+
yield manager
37+
38+
# Cleanup
39+
manager.stop()
40+
try:
41+
os.remove(dotenv_path)
42+
except:
43+
pass
44+
45+
46+
class TestEnvSecretsManager():
47+
"""
48+
Unit tests for the EnvSecretsManager.
49+
50+
At the start of each test, no secrets are set in the environment variables
51+
passed to the process and no `.env` file is set.
52+
"""
53+
54+
def test_initial_state(self, manager: EnvSecretsManager, dotenv_path: Path):
55+
"""
56+
ASSERTS:
57+
58+
1. `list_secrets()` returns an empty `SecretsList` response.
59+
60+
2. No `.env` file is created.
61+
"""
62+
# Assertion 1
63+
response = manager.list_secrets()
64+
assert isinstance(response, SecretsList)
65+
assert response.static_secrets == []
66+
assert response.editable_secrets == []
67+
68+
# Assertion 2
69+
assert not os.path.exists(dotenv_path)
70+
71+
async def test_adding_secrets_via_manager(self, manager: EnvSecretsManager, dotenv_path: Path):
72+
"""
73+
ASSERTS:
74+
75+
1. Adding a secret via `update_secret()` creates a `.env` file
76+
under `tmp_path`.
77+
78+
2. Adding another secret results in 2 secrets listed in `.env`.
79+
80+
3. `list_secrets()` lists the 2 newly added secrets as editable secrets.
81+
82+
4. Both secrets are set in `os.environ`.
83+
"""
84+
# Assertion 1
85+
await manager.update_secrets({
86+
"NEW_API_KEY": "some_value"
87+
})
88+
assert os.path.exists(dotenv_path)
89+
with open(dotenv_path) as f:
90+
content = f.read()
91+
assert content.strip() == 'NEW_API_KEY="some_value"'
92+
93+
# Assertion 2
94+
await manager.update_secrets({
95+
"ANOTHER_API_KEY": "some_value"
96+
})
97+
with open(dotenv_path) as f:
98+
content = f.read()
99+
lines = content.strip().splitlines()
100+
assert 'NEW_API_KEY="some_value"' in lines
101+
assert 'ANOTHER_API_KEY="some_value"' in lines
102+
103+
# Assertion 3
104+
list_secrets_response = manager.list_secrets()
105+
assert list_secrets_response.static_secrets == []
106+
assert list_secrets_response.editable_secrets == sorted([
107+
"NEW_API_KEY",
108+
"ANOTHER_API_KEY"
109+
])
110+
111+
# Assertion 4
112+
assert os.environ["NEW_API_KEY"] == "some_value"
113+
assert os.environ["ANOTHER_API_KEY"] == "some_value"
114+
115+
async def test_updating_secrets_via_manager(self, manager: EnvSecretsManager, dotenv_path: Path):
116+
"""
117+
ASSERTS:
118+
119+
1. A previously added secret can be updated.
120+
121+
2. Only a single secret is listed in the `.env` file and the
122+
`list_secrets()` response.
123+
124+
3. The new value is set in `os.environ`.
125+
"""
126+
# Assertion 1
127+
await manager.update_secrets({
128+
"API_KEY": "old_value"
129+
})
130+
assert os.environ["API_KEY"] == "old_value"
131+
await manager.update_secrets({
132+
"API_KEY": "new_value"
133+
})
134+
135+
# Assertion 2
136+
with open(dotenv_path) as f:
137+
content = f.read()
138+
assert content.strip() == 'API_KEY="new_value"'
139+
assert manager.list_secrets().static_secrets == []
140+
assert manager.list_secrets().editable_secrets == ["API_KEY"]
141+
142+
# Assertion 3
143+
assert os.environ["API_KEY"] == "new_value"
144+
145+
146+
async def test_adding_secrets_via_dotenv(self, manager: EnvSecretsManager, dotenv_path: Path):
147+
"""
148+
ASSERTS:
149+
150+
1. After creating a new `.env` file with secrets and waiting a few
151+
seconds, the secrets manager lists secrets from the new `.env` file.
152+
153+
2. The new secrets are set in `os.environ`.
154+
"""
155+
# Assertion 1
156+
with open(dotenv_path, "w") as f:
157+
f.write('API_KEY_1="api_key_1_value"\n')
158+
f.write('API_KEY_2="api_key_2_value"\n')
159+
await asyncio.sleep(3)
160+
assert manager.list_secrets().static_secrets == []
161+
assert manager.list_secrets().editable_secrets == ["API_KEY_1", "API_KEY_2"]
162+
163+
# Assertion 2
164+
assert os.environ["API_KEY_1"] == "api_key_1_value"
165+
assert os.environ["API_KEY_2"] == "api_key_2_value"
166+
167+
async def test_updating_secrets_via_dotenv(self, manager: EnvSecretsManager, dotenv_path: Path):
168+
"""
169+
ASSERTS: After updating an existing `.env` file directly (i.e. through the
170+
filesystem and not the secrets manager) and waiting a few seconds, the
171+
new value is set in `os.environ`.
172+
"""
173+
await manager.update_secrets({"API_KEY": "old_value"})
174+
with open(dotenv_path, "w") as f:
175+
f.write('API_KEY="new_value"')
176+
await asyncio.sleep(3)
177+
assert os.environ["API_KEY"] == "new_value"
178+
179+
180+
class TestEnvSecretsManagerWithInitialEnv():
181+
"""
182+
Unit tests for the `EnvSecretsManager` in the special scenario where secrets
183+
are set in `os.environ` prior to each test.
184+
"""
185+
# TODO
186+
pass

packages/jupyter-ai/jupyter_ai/secrets/test_secrets_utils.py renamed to packages/jupyter-ai/jupyter_ai/secrets/tests/test_secrets_utils.py

File renamed without changes.

packages/jupyter-ai/jupyter_ai/tests/test_config_manager.py

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -410,27 +410,6 @@ def test_forbid_write_write_conflict(cm: ConfigManager):
410410
)
411411

412412

413-
def test_update_api_key(cm: ConfigManager):
414-
"""Asserts that updates via direct edits to the config file are immediately
415-
reflected by the ConfigManager."""
416-
LM_GID, EM_GID, LM_LID, EM_LID, _ = configure_to_cohere(cm)
417-
cm.update_config(UpdateConfigRequest(api_keys={"COHERE_API_KEY": "barfoo"}))
418-
419-
config_desc = cm.get_config()
420-
assert config_desc.api_keys == ["COHERE_API_KEY"]
421-
assert cm.lm_provider_params == {"cohere_api_key": "barfoo", "model_id": LM_LID}
422-
assert cm.em_provider_params == {"cohere_api_key": "barfoo", "model_id": EM_LID}
423-
424-
425-
def test_delete_api_key(cm: ConfigManager):
426-
configure_to_cohere(cm)
427-
cm.update_config(UpdateConfigRequest(api_keys={"OPENAI_API_KEY": "asdf"}))
428-
assert cm.get_config().api_keys == ["COHERE_API_KEY", "OPENAI_API_KEY"]
429-
430-
cm.delete_api_key("OPENAI_API_KEY")
431-
assert cm.get_config().api_keys == ["COHERE_API_KEY"]
432-
433-
434413
def test_forbid_deleting_key_in_use(cm: ConfigManager):
435414
configure_to_cohere(cm)
436415

0 commit comments

Comments
 (0)