redenv 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.0] - 2026-01-25
6
+
7
+ ### Added
8
+
9
+ - **Secret Expansion:** Support for `${VAR_NAME}` syntax for referencing other secrets within values.
10
+ - **Raw Value Access:** Added `.raw` property to `Secrets` object to access unexpanded values.
11
+ - **Recursive Resolution:** Variable expansion supports multi-level recursion with circular dependency detection.
12
+
13
+ ### Changed
14
+
15
+ - **Safe Access:** Accessing a non-existent secret via `secrets["KEY"]` now returns `None` instead of raising a `KeyError`.
16
+ - **Improved Scoping:** `secrets.scope()` now correctly preserves both expanded and raw values in the resulting subset.
17
+
5
18
  ## [0.2.0] - 2026-01-22
6
19
 
7
20
  ### Added
@@ -10,15 +10,18 @@ test:
10
10
 
11
11
  # Run linting and type checking
12
12
  lint:
13
- python -m ruff check src tests
13
+ python -m ruff check src
14
14
  python -m pyright src
15
15
 
16
16
  # Build the package artifacts
17
17
  build:
18
- rm -rf dist/ build/
18
+ rm -rf dist/
19
19
  python -m build
20
20
 
21
21
  # Clean up build artifacts and caches
22
22
  clean:
23
23
  rm -rf dist/ build/ *.egg-info .pytest_cache .ruff_cache
24
24
  find . -type d -name "__pycache__" -exec rm -rf {} +
25
+
26
+ publish:
27
+ twine upload dist/*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redenv
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A zero-knowledge, end-to-end encrypted secret management SDK for Python.
5
5
  Project-URL: Homepage, https://github.com/redenv-labs/redenv
6
6
  Project-URL: Documentation, https://github.com/redenv-labs/redenv/tree/main/packages/python-client
@@ -50,6 +50,7 @@ Provides-Extra: dev
50
50
  Requires-Dist: build; extra == 'dev'
51
51
  Requires-Dist: pyright; extra == 'dev'
52
52
  Requires-Dist: pytest; extra == 'dev'
53
+ Requires-Dist: pytest-asyncio; extra == 'dev'
53
54
  Requires-Dist: python-dotenv; extra == 'dev'
54
55
  Requires-Dist: ruff; extra == 'dev'
55
56
  Requires-Dist: twine; extra == 'dev'
@@ -110,6 +111,12 @@ async def main():
110
111
  # 3. Smart Casting
111
112
  port = secrets.get("PORT", cast=int)
112
113
  debug = secrets.get("DEBUG", cast=bool)
114
+
115
+ # 4. Safe Access (Returns None if missing)
116
+ missing = secrets["MISSING_KEY"] # None
117
+
118
+ # 5. Fallback Values
119
+ timeout = secrets.get("TIMEOUT", default=30, cast=int)
113
120
 
114
121
  if __name__ == "__main__":
115
122
  asyncio.run(main())
@@ -132,7 +139,30 @@ print(secrets["API_KEY"])
132
139
 
133
140
  ## Advanced Usage
134
141
 
135
- ### 1. Scoping & Validation
142
+ ### 1. Secret Expansion (Reference other keys)
143
+ Redenv supports referencing other secrets using the `${VAR_NAME}` syntax. This helps avoid duplication.
144
+
145
+ **Example Configuration:**
146
+ - `BASE_URL`: `https://api.example.com`
147
+ - `AUTH_URL`: `${BASE_URL}/auth`
148
+
149
+ **Usage:**
150
+ ```python
151
+ secrets = await client.load()
152
+
153
+ print(secrets["AUTH_URL"])
154
+ # Output: https://api.example.com/auth
155
+ ```
156
+
157
+ ### 2. Raw Values
158
+ You can access the unexpanded, raw value of a secret using the `.raw` property. This is useful for debugging or editing.
159
+
160
+ ```python
161
+ print(secrets["AUTH_URL"]) # https://api.example.com/auth
162
+ print(secrets.raw["AUTH_URL"]) # ${BASE_URL}/auth
163
+ ```
164
+
165
+ ### 3. Scoping & Validation
136
166
  Organize large configurations and ensure critical keys exist.
137
167
 
138
168
  ```python
@@ -149,7 +179,7 @@ print(stripe_config["KEY"]) # Maps to STRIPE_KEY
149
179
  print(stripe_config["WEBHOOK"]) # Maps to STRIPE_WEBHOOK
150
180
  ```
151
181
 
152
- ### 2. Time Travel (Version History)
182
+ ### 4. Time Travel (Version History)
153
183
  Redenv stores a history of every secret change. You can access older versions for rollbacks or auditing.
154
184
 
155
185
  ```python
@@ -53,6 +53,12 @@ async def main():
53
53
  # 3. Smart Casting
54
54
  port = secrets.get("PORT", cast=int)
55
55
  debug = secrets.get("DEBUG", cast=bool)
56
+
57
+ # 4. Safe Access (Returns None if missing)
58
+ missing = secrets["MISSING_KEY"] # None
59
+
60
+ # 5. Fallback Values
61
+ timeout = secrets.get("TIMEOUT", default=30, cast=int)
56
62
 
57
63
  if __name__ == "__main__":
58
64
  asyncio.run(main())
@@ -75,7 +81,30 @@ print(secrets["API_KEY"])
75
81
 
76
82
  ## Advanced Usage
77
83
 
78
- ### 1. Scoping & Validation
84
+ ### 1. Secret Expansion (Reference other keys)
85
+ Redenv supports referencing other secrets using the `${VAR_NAME}` syntax. This helps avoid duplication.
86
+
87
+ **Example Configuration:**
88
+ - `BASE_URL`: `https://api.example.com`
89
+ - `AUTH_URL`: `${BASE_URL}/auth`
90
+
91
+ **Usage:**
92
+ ```python
93
+ secrets = await client.load()
94
+
95
+ print(secrets["AUTH_URL"])
96
+ # Output: https://api.example.com/auth
97
+ ```
98
+
99
+ ### 2. Raw Values
100
+ You can access the unexpanded, raw value of a secret using the `.raw` property. This is useful for debugging or editing.
101
+
102
+ ```python
103
+ print(secrets["AUTH_URL"]) # https://api.example.com/auth
104
+ print(secrets.raw["AUTH_URL"]) # ${BASE_URL}/auth
105
+ ```
106
+
107
+ ### 3. Scoping & Validation
79
108
  Organize large configurations and ensure critical keys exist.
80
109
 
81
110
  ```python
@@ -92,7 +121,7 @@ print(stripe_config["KEY"]) # Maps to STRIPE_KEY
92
121
  print(stripe_config["WEBHOOK"]) # Maps to STRIPE_WEBHOOK
93
122
  ```
94
123
 
95
- ### 2. Time Travel (Version History)
124
+ ### 4. Time Travel (Version History)
96
125
  Redenv stores a history of every secret change. You can access older versions for rollbacks or auditing.
97
126
 
98
127
  ```python
@@ -4,7 +4,6 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "redenv"
7
- version = "0.2.0"
8
7
  description = "A zero-knowledge, end-to-end encrypted secret management SDK for Python."
9
8
  readme = "README.md"
10
9
  requires-python = ">=3.8"
@@ -33,6 +32,11 @@ dependencies = [
33
32
  "cachetools>=5.0.0",
34
33
  ]
35
34
 
35
+ dynamic = ["version"]
36
+
37
+ [tool.hatch.version]
38
+ path = "src/redenv/__init__.py"
39
+
36
40
  [project.urls]
37
41
  Homepage = "https://github.com/redenv-labs/redenv"
38
42
  Documentation = "https://github.com/redenv-labs/redenv/tree/main/packages/python-client"
@@ -42,6 +46,7 @@ Issues = "https://github.com/redenv-labs/redenv/issues"
42
46
  [project.optional-dependencies]
43
47
  dev = [
44
48
  "pytest",
49
+ "pytest-asyncio",
45
50
  "python-dotenv",
46
51
  "build",
47
52
  "twine",
@@ -3,5 +3,5 @@ from .errors import RedenvError
3
3
  from .secrets import Secrets
4
4
  from .sync import Redenv as RedenvSync
5
5
 
6
- __version__ = "0.2.0"
6
+ __version__ = "0.3.0"
7
7
  __all__ = ["Redenv", "RedenvSync", "RedenvError", "Secrets"]
@@ -0,0 +1,45 @@
1
+ import re
2
+ from typing import Dict
3
+ from .errors import RedenvError
4
+
5
+ # Match ${VAR} not preceded by a backslash
6
+ REFERENCE_REGEX = re.compile(r"(?<!\\)\$\{([a-zA-Z0-9_]+)\}")
7
+
8
+ def expand_secrets(secrets: Dict[str, str]) -> Dict[str, str]:
9
+ r"""
10
+ Expands variable references in a dictionary of secrets.
11
+ Supports recursion, cycle detection, and escaped references (\${VAR}).
12
+ """
13
+ expanded: Dict[str, str] = {}
14
+ cache: Dict[str, str] = {}
15
+ stack: list[str] = []
16
+
17
+ def resolve(key: str) -> str:
18
+ if key in cache:
19
+ return cache[key]
20
+
21
+ if key in stack:
22
+ cycle = " -> ".join(stack + [key])
23
+ raise RedenvError(f"Circular dependency detected: {cycle}", "INVALID_INPUT")
24
+
25
+ stack.append(key)
26
+ value = secrets[key]
27
+
28
+ def replacer(match: re.Match) -> str:
29
+ ref_key = match.group(1)
30
+ return resolve(ref_key) if ref_key in secrets else match.group(0)
31
+
32
+ # Expand all unescaped references
33
+ resolved = REFERENCE_REGEX.sub(replacer, value)
34
+
35
+ # Convert escaped references \${VAR} -> ${VAR}
36
+ resolved = resolved.replace(r"\${", "${")
37
+
38
+ cache[key] = resolved
39
+ stack.pop()
40
+ return resolved
41
+
42
+ for key in secrets:
43
+ expanded[key] = resolve(key)
44
+
45
+ return expanded
@@ -10,6 +10,18 @@ class Secrets(dict):
10
10
  type-casting, scoping, and validation capabilities.
11
11
  """
12
12
 
13
+ def __init__(self, data: Optional[dict] = None, raw_data: Optional[dict] = None):
14
+ super().__init__(data or {})
15
+ self._raw_data = raw_data or data or {}
16
+
17
+ @property
18
+ def raw(self) -> "Secrets":
19
+ """
20
+ Returns a Secrets object containing the raw, unexpanded values.
21
+ """
22
+ # Create a new Secrets object with the raw data, but mark it as its own raw
23
+ return Secrets(self._raw_data, raw_data=self._raw_data)
24
+
13
25
  def get(self, key: str, default: Any = None, cast: Optional[Union[Type[T], Callable[[Any], T]]] = None) -> Union[T, Any]:
14
26
  """
15
27
  Retrieves a secret and optionally casts it to a specific type.
@@ -46,26 +58,31 @@ class Secrets(dict):
46
58
 
47
59
  def __getitem__(self, key: str) -> Any:
48
60
  """
49
- Ensures standard dict access still works but raises a
50
- helpful error if the key is missing.
61
+ Returns None if the key is not found instead of raising KeyError.
51
62
  """
52
- try:
53
- return super().__getitem__(key)
54
- except KeyError:
55
- raise KeyError(f"Secret '{key}' not found in Redenv.")
63
+ return self.get(key)
56
64
 
57
65
  def scope(self, prefix: str) -> "Secrets":
58
66
  """
59
67
  Returns a new Secrets object containing only the keys that start with
60
68
  the given prefix. The prefix is stripped from the keys in the new object.
61
69
  """
62
- subset = Secrets()
70
+ subset_data = {}
71
+ subset_raw = {}
72
+
63
73
  for key, value in self.items():
64
74
  if key.startswith(prefix):
65
75
  new_key = key[len(prefix):]
66
76
  if new_key:
67
- subset[new_key] = value
68
- return subset
77
+ subset_data[new_key] = value
78
+
79
+ for key, value in self._raw_data.items():
80
+ if key.startswith(prefix):
81
+ new_key = key[len(prefix):]
82
+ if new_key:
83
+ subset_raw[new_key] = value
84
+
85
+ return Secrets(subset_data, raw_data=subset_raw)
69
86
 
70
87
  def require(self, *keys: str) -> "Secrets":
71
88
  """
@@ -7,6 +7,7 @@ from ..crypto import derive_key, decrypt, hex_to_buffer, encrypt
7
7
  from ..types import RedenvOptions, CacheEntry
8
8
  from ..errors import RedenvError
9
9
  from ..secrets import Secrets
10
+ from ..expand import expand_secrets
10
11
  from ..utils import log, error
11
12
  from cachetools import LRUCache
12
13
 
@@ -79,8 +80,17 @@ def fetch_and_decrypt(redis: SyncRedis, options: RedenvOptions) -> Secrets:
79
80
  error(f'Failed to decrypt secret "{key}".', options.log)
80
81
  continue
81
82
 
82
- log(f"Successfully loaded {len(secrets)} secrets.", options.log)
83
- return secrets
83
+ # Capture raw decrypted secrets before expansion
84
+ raw_decrypted = dict(secrets)
85
+
86
+ # Expand variables
87
+ expanded_secrets = expand_secrets(raw_decrypted)
88
+
89
+ # Create final Secrets object with both expanded and raw data
90
+ result = Secrets(expanded_secrets, raw_data=raw_decrypted)
91
+
92
+ log(f"Successfully loaded {len(result)} secrets.", options.log)
93
+ return result
84
94
 
85
95
  def populate_env(secrets: Union[Dict[str, str], Secrets], options: RedenvOptions):
86
96
  """
@@ -1,6 +1,7 @@
1
1
  from .crypto import derive_key, decrypt, hex_to_buffer, encrypt
2
2
  from .types import RedenvOptions, LogPreference, CacheEntry
3
3
  from .errors import RedenvError
4
+ from .expand import expand_secrets
4
5
  from upstash_redis import AsyncRedis
5
6
  from .secrets import Secrets
6
7
  import asyncio
@@ -114,8 +115,17 @@ async def fetch_and_decrypt(redis: AsyncRedis, options: RedenvOptions) -> Secret
114
115
  error(f'Failed to decrypt secret "{key}".', options.log)
115
116
  continue
116
117
 
117
- log(f"Successfully loaded {len(secrets)} secrets.", options.log)
118
- return secrets
118
+ # Capture raw decrypted secrets before expansion
119
+ raw_decrypted = dict(secrets)
120
+
121
+ # Expand variables
122
+ expanded_secrets = expand_secrets(raw_decrypted)
123
+
124
+ # Create final Secrets object with both expanded and raw data
125
+ result = Secrets(expanded_secrets, raw_data=raw_decrypted)
126
+
127
+ log(f"Successfully loaded {len(result)} secrets.", options.log)
128
+ return result
119
129
 
120
130
  async def populate_env(secrets: Union[Dict[str, str], Secrets], options: RedenvOptions):
121
131
  """
@@ -0,0 +1,135 @@
1
+ import pytest
2
+ import json
3
+ from unittest.mock import AsyncMock, patch
4
+ from redenv import Redenv
5
+ from redenv.crypto import encrypt, derive_key, buffer_to_hex, random_bytes
6
+
7
+ # --- Test Data Setup ---
8
+ PASSWORD = "masterpassword"
9
+ SALT = random_bytes(16)
10
+ TOKEN_SALT = random_bytes(16)
11
+ TOKEN_SECRET = "redenv_sk_test"
12
+ TOKEN_ID = "stk_test"
13
+
14
+ # 1. Derive PEK
15
+ PEK = derive_key(PASSWORD, SALT) # Project Encryption Key
16
+
17
+ # 2. Encrypt PEK with Service Token
18
+ token_key = derive_key(TOKEN_SECRET, TOKEN_SALT)
19
+ encrypted_pek = encrypt(buffer_to_hex(PEK), token_key) # Wait, exportKey not implemented in python utils, we used bytes.
20
+ # In utils.py get_pek: decrypted_pek_hex = decrypt(..., token_key) -> return hex_to_buffer(decrypted_pek_hex)
21
+ # So we need to encrypt the HEX string of the PEK.
22
+ pek_hex = buffer_to_hex(PEK)
23
+ encrypted_pek_str = encrypt(pek_hex, token_key)
24
+
25
+ # 3. Create Metadata
26
+ METADATA = {
27
+ "serviceTokens": json.dumps({
28
+ TOKEN_ID: {
29
+ "salt": buffer_to_hex(TOKEN_SALT),
30
+ "encryptedPEK": encrypted_pek_str,
31
+ "name": "Test Token"
32
+ }
33
+ }),
34
+ "historyLimit": "10"
35
+ }
36
+
37
+ # 4. Create Secret Data
38
+ SECRET_KEY = "API_KEY"
39
+ SECRET_VAL = "super-secret-value"
40
+ encrypted_secret = encrypt(SECRET_VAL, PEK)
41
+
42
+ SECRET_HISTORY = [
43
+ {
44
+ "version": 1,
45
+ "value": encrypted_secret,
46
+ "user": "test-user",
47
+ "createdAt": "2023-01-01T00:00:00Z"
48
+ }
49
+ ]
50
+
51
+ ENVIRONMENT_DATA = {
52
+ SECRET_KEY: json.dumps(SECRET_HISTORY)
53
+ }
54
+
55
+ # --- Client Setup ---
56
+ @pytest.fixture
57
+ def mock_redis():
58
+ mock = AsyncMock()
59
+ # Mock hgetall responses
60
+ def side_effect(key):
61
+ if key == "meta@test-project":
62
+ return METADATA
63
+ if key == "dev:test-project":
64
+ return ENVIRONMENT_DATA
65
+ return {}
66
+
67
+ mock.hgetall.side_effect = side_effect
68
+
69
+ # Mock hget response (for set_secret and get_version)
70
+ async def hget_side_effect(key, field):
71
+ if key == "dev:test-project" and field == SECRET_KEY:
72
+ return json.dumps(SECRET_HISTORY)
73
+ return None
74
+
75
+ mock.hget.side_effect = hget_side_effect
76
+
77
+ return mock
78
+
79
+ @pytest.fixture
80
+ def client(mock_redis):
81
+ with patch("redenv.client.Redis", return_value=mock_redis):
82
+ client = Redenv({
83
+ "project": "test-project",
84
+ "token_id": TOKEN_ID,
85
+ "token": TOKEN_SECRET,
86
+ "environment": "dev",
87
+ "upstash": {"url": "https://mock", "token": "mock"},
88
+ "log": "none"
89
+ })
90
+ return client
91
+
92
+ @pytest.mark.asyncio
93
+ async def test_load_decrypts_successfully(client, mock_redis):
94
+ secrets = await client.load()
95
+ assert secrets[SECRET_KEY] == SECRET_VAL
96
+ assert mock_redis.hgetall.call_count == 2 # 1 meta, 1 env
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_caching_behavior(client, mock_redis):
100
+ # First load
101
+ await client.load()
102
+ assert mock_redis.hgetall.call_count == 2
103
+
104
+ # Second load (should hit cache)
105
+ await client.load()
106
+ assert mock_redis.hgetall.call_count == 2 # Counts shouldn't increase
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_write_secret(client, mock_redis):
110
+ new_val = "new-value"
111
+ await client.set(SECRET_KEY, new_val)
112
+
113
+ # Verify hset was called
114
+ args = mock_redis.hset.call_args
115
+ # hset(key, field, value)
116
+ assert args[0][0] == "dev:test-project"
117
+ assert args[0][1] == SECRET_KEY
118
+
119
+ written_json = args[0][2]
120
+ history = json.loads(written_json)
121
+
122
+ assert len(history) == 2 # Prepend new version
123
+ assert history[0]["version"] == 2
124
+ # We can't verify encrypted value easily without decrypting,
125
+ # but we assume encrypt() works (tested in unit tests)
126
+
127
+ @pytest.mark.asyncio
128
+ async def test_get_version(client, mock_redis):
129
+ # Version 1 exists in our mock data
130
+ val = await client.get_version(SECRET_KEY, 1)
131
+ assert val == SECRET_VAL
132
+
133
+ # Version 99 does not exist
134
+ val_missing = await client.get_version(SECRET_KEY, 99)
135
+ assert val_missing is None
@@ -0,0 +1,59 @@
1
+ import pytest
2
+ from redenv.expand import expand_secrets
3
+ from redenv.errors import RedenvError
4
+
5
+ def test_basic_expansion():
6
+ secrets = {
7
+ "BASE_URL": "https://api.example.com",
8
+ "AUTH_URL": "${BASE_URL}/auth"
9
+ }
10
+ expanded = expand_secrets(secrets)
11
+ assert expanded["AUTH_URL"] == "https://api.example.com/auth"
12
+
13
+ def test_recursive_expansion():
14
+ secrets = {
15
+ "A": "value",
16
+ "B": "${A}",
17
+ "C": "${B}"
18
+ }
19
+ expanded = expand_secrets(secrets)
20
+ assert expanded["C"] == "value"
21
+
22
+ def test_multiple_references():
23
+ secrets = {
24
+ "PROTOCOL": "https",
25
+ "DOMAIN": "example.com",
26
+ "URL": "${PROTOCOL}://${DOMAIN}"
27
+ }
28
+ expanded = expand_secrets(secrets)
29
+ assert expanded["URL"] == "https://example.com"
30
+
31
+ def test_circular_dependency():
32
+ secrets = {
33
+ "A": "${B}",
34
+ "B": "${A}"
35
+ }
36
+ with pytest.raises(RedenvError, match="Circular dependency detected"):
37
+ expand_secrets(secrets)
38
+
39
+ def test_missing_reference():
40
+ secrets = {
41
+ "A": "${MISSING}"
42
+ }
43
+ expanded = expand_secrets(secrets)
44
+ assert expanded["A"] == "${MISSING}"
45
+
46
+ def test_escaped_reference():
47
+ secrets = {
48
+ "A": r"\${NOT_EXPANDED}"
49
+ }
50
+ expanded = expand_secrets(secrets)
51
+ assert expanded["A"] == "${NOT_EXPANDED}"
52
+
53
+ def test_mixed_escaped_and_normal():
54
+ secrets = {
55
+ "VAR": "value",
56
+ "A": r"\${VAR} and ${VAR}"
57
+ }
58
+ expanded = expand_secrets(secrets)
59
+ assert expanded["A"] == "${VAR} and value"
@@ -0,0 +1,96 @@
1
+ import pytest
2
+ from redenv.secrets import Secrets
3
+ from redenv.errors import RedenvError
4
+
5
+ def test_secrets_access():
6
+ data = {"KEY": "value"}
7
+ secrets = Secrets(data)
8
+ assert secrets["KEY"] == "value"
9
+ assert secrets.get("KEY") == "value"
10
+
11
+ # Missing keys should return None instead of raising KeyError
12
+ assert secrets["MISSING"] is None
13
+
14
+ def test_secrets_cast_int():
15
+ secrets = Secrets({"PORT": "8080", "BAD": "abc"})
16
+ assert secrets.get("PORT", cast=int) == 8080
17
+ assert secrets.get("BAD", cast=int) == None # Defaults to None on failure
18
+ assert secrets.get("BAD", default=0, cast=int) == 0
19
+
20
+ def test_secrets_cast_bool():
21
+ secrets = Secrets({
22
+ "FLAG_TRUE": "true",
23
+ "FLAG_1": "1",
24
+ "FLAG_YES": "yes",
25
+ "FLAG_FALSE": "false",
26
+ "FLAG_0": "0"
27
+ })
28
+
29
+ assert secrets.get("FLAG_TRUE", cast=bool) is True
30
+ assert secrets.get("FLAG_1", cast=bool) is True
31
+ assert secrets.get("FLAG_YES", cast=bool) is True
32
+ assert secrets.get("FLAG_FALSE", cast=bool) is False
33
+ assert secrets.get("FLAG_0", cast=bool) is False
34
+
35
+ def test_secrets_cast_json():
36
+ secrets = Secrets({"CONFIG": '{"foo": "bar"}'})
37
+ config = secrets.get("CONFIG", cast=dict)
38
+ assert isinstance(config, dict)
39
+ assert config["foo"] == "bar"
40
+
41
+ def test_secrets_scoping():
42
+ secrets = Secrets({
43
+ "APP_NAME": "MyApp",
44
+ "STRIPE_KEY": "sk_123",
45
+ "STRIPE_SECRET": "wh_456"
46
+ })
47
+
48
+ stripe = secrets.scope("STRIPE_")
49
+
50
+ # Prefix should be stripped
51
+ assert stripe["KEY"] == "sk_123"
52
+ assert stripe["SECRET"] == "wh_456"
53
+ # Unrelated keys should be ignored
54
+ assert "APP_NAME" not in stripe
55
+ # Original object should be untouched
56
+ assert secrets["STRIPE_KEY"] == "sk_123"
57
+
58
+ def test_secrets_require():
59
+ secrets = Secrets({"A": "1", "B": "2"})
60
+
61
+ # Should pass (chainable)
62
+ assert secrets.require("A", "B") is secrets
63
+
64
+ # Should fail
65
+ with pytest.raises(RedenvError, match="Missing required secrets: C"):
66
+ secrets.require("A", "C")
67
+
68
+ def test_secrets_masking():
69
+ secrets = Secrets({"API_KEY": "secret_value"})
70
+ output = str(secrets)
71
+
72
+ assert "API_KEY" in output
73
+ assert "secret_value" not in output
74
+ assert "********" in output
75
+
76
+ def test_secrets_raw_access():
77
+ raw_data = {"BASE": "val", "URL": "${BASE}/path"}
78
+ expanded_data = {"BASE": "val", "URL": "val/path"}
79
+
80
+ secrets = Secrets(expanded_data, raw_data=raw_data)
81
+
82
+ assert secrets["URL"] == "val/path"
83
+ assert secrets.raw["URL"] == "${BASE}/path"
84
+ assert secrets.raw["BASE"] == "val"
85
+
86
+ # Test scoping raw access
87
+ stripe_raw = {"STRIPE_KEY": "${BASE}/key"}
88
+ stripe_expanded = {"STRIPE_KEY": "val/key"}
89
+ full_secrets = Secrets(
90
+ {**expanded_data, **stripe_expanded},
91
+ raw_data={**raw_data, **stripe_raw}
92
+ )
93
+
94
+ stripe_scope = full_secrets.scope("STRIPE_")
95
+ assert stripe_scope["KEY"] == "val/key"
96
+ assert stripe_scope.raw["KEY"] == "${BASE}/key"
@@ -0,0 +1,123 @@
1
+ import pytest
2
+ import json
3
+ from unittest.mock import MagicMock, patch
4
+ from redenv.sync import Redenv as RedenvSync
5
+ from redenv.crypto import encrypt, derive_key, buffer_to_hex, random_bytes
6
+
7
+ # --- Test Data Setup (Mirroring test_client.py) ---
8
+ PASSWORD = "masterpassword"
9
+ SALT = random_bytes(16)
10
+ TOKEN_SALT = random_bytes(16)
11
+ TOKEN_SECRET = "redenv_sk_test"
12
+ TOKEN_ID = "stk_test"
13
+
14
+ # 1. Setup Keys
15
+ PEK = derive_key(PASSWORD, SALT)
16
+ token_key = derive_key(TOKEN_SECRET, TOKEN_SALT)
17
+ pek_hex = buffer_to_hex(PEK)
18
+ encrypted_pek_str = encrypt(pek_hex, token_key)
19
+
20
+ # 2. Metadata
21
+ METADATA = {
22
+ "serviceTokens": json.dumps({
23
+ TOKEN_ID: {
24
+ "salt": buffer_to_hex(TOKEN_SALT),
25
+ "encryptedPEK": encrypted_pek_str,
26
+ "name": "Test Token"
27
+ }
28
+ }),
29
+ "historyLimit": "10"
30
+ }
31
+
32
+ # 3. Secret Data
33
+ SECRET_KEY = "SYNC_API_KEY"
34
+ SECRET_VAL = "sync-secret-value"
35
+ encrypted_secret = encrypt(SECRET_VAL, PEK)
36
+
37
+ SECRET_HISTORY = [
38
+ {
39
+ "version": 1,
40
+ "value": encrypted_secret,
41
+ "user": "sync-user",
42
+ "createdAt": "2023-01-01T00:00:00Z"
43
+ }
44
+ ]
45
+
46
+ ENVIRONMENT_DATA = {
47
+ SECRET_KEY: json.dumps(SECRET_HISTORY)
48
+ }
49
+
50
+ # --- Sync Client Fixtures ---
51
+ @pytest.fixture
52
+ def mock_redis_sync():
53
+ mock = MagicMock()
54
+
55
+ # Mock hgetall
56
+ def hgetall_side_effect(key):
57
+ if key == "meta@sync-project":
58
+ return METADATA
59
+ if key == "prod:sync-project":
60
+ return ENVIRONMENT_DATA
61
+ return {}
62
+ mock.hgetall.side_effect = hgetall_side_effect
63
+
64
+ # Mock hget
65
+ def hget_side_effect(key, field):
66
+ if key == "prod:sync-project" and field == SECRET_KEY:
67
+ return json.dumps(SECRET_HISTORY)
68
+ return None
69
+ mock.hget.side_effect = hget_side_effect
70
+
71
+ return mock
72
+
73
+ @pytest.fixture
74
+ def sync_client(mock_redis_sync):
75
+ # Important: patch where Redis is IMPORTED in the sync client
76
+ with patch("redenv.sync.client.Redis", return_value=mock_redis_sync):
77
+ client = RedenvSync({
78
+ "project": "sync-project",
79
+ "token_id": TOKEN_ID,
80
+ "token": TOKEN_SECRET,
81
+ "environment": "prod",
82
+ "upstash": {"url": "https://mock", "token": "mock"},
83
+ "log": "none"
84
+ })
85
+ return client
86
+
87
+ # --- Sync Tests ---
88
+ def test_sync_load_decrypts(sync_client, mock_redis_sync):
89
+ secrets = sync_client.load()
90
+ assert secrets[SECRET_KEY] == SECRET_VAL
91
+ # Check that it called Redis
92
+ assert mock_redis_sync.hgetall.called
93
+
94
+ def test_sync_caching(sync_client, mock_redis_sync):
95
+ # First load
96
+ sync_client.load()
97
+ count_after_first = mock_redis_sync.hgetall.call_count
98
+
99
+ # Second load (should hit cache)
100
+ sync_client.load()
101
+ assert mock_redis_sync.hgetall.call_count == count_after_first
102
+
103
+ def test_sync_set_secret(sync_client, mock_redis_sync):
104
+ new_val = "new-sync-val"
105
+ sync_client.set(SECRET_KEY, new_val)
106
+
107
+ # Verify hset was called
108
+ assert mock_redis_sync.hset.called
109
+ args = mock_redis_sync.hset.call_args
110
+ assert args[0][0] == "prod:sync-project"
111
+ assert args[0][1] == SECRET_KEY
112
+
113
+ written_json = args[0][2]
114
+ history = json.loads(written_json)
115
+ assert history[0]["version"] == 2
116
+
117
+ def test_sync_get_version(sync_client, mock_redis_sync):
118
+ # Valid version
119
+ val = sync_client.get_version(SECRET_KEY, 1)
120
+ assert val == SECRET_VAL
121
+
122
+ # Invalid version
123
+ assert sync_client.get_version(SECRET_KEY, 999) is None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes