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.
- {redenv-0.2.0 → redenv-0.3.0}/CHANGELOG.md +13 -0
- {redenv-0.2.0 → redenv-0.3.0}/Makefile +5 -2
- {redenv-0.2.0 → redenv-0.3.0}/PKG-INFO +33 -3
- {redenv-0.2.0 → redenv-0.3.0}/README.md +31 -2
- {redenv-0.2.0 → redenv-0.3.0}/pyproject.toml +6 -1
- {redenv-0.2.0 → redenv-0.3.0}/src/redenv/__init__.py +1 -1
- redenv-0.3.0/src/redenv/expand.py +45 -0
- {redenv-0.2.0 → redenv-0.3.0}/src/redenv/secrets.py +26 -9
- {redenv-0.2.0 → redenv-0.3.0}/src/redenv/sync/utils.py +12 -2
- {redenv-0.2.0 → redenv-0.3.0}/src/redenv/utils.py +12 -2
- redenv-0.3.0/tests/test_client.py +135 -0
- redenv-0.3.0/tests/test_expand.py +59 -0
- redenv-0.3.0/tests/test_secrets.py +96 -0
- redenv-0.3.0/tests/test_sync.py +123 -0
- {redenv-0.2.0 → redenv-0.3.0}/.gitignore +0 -0
- {redenv-0.2.0 → redenv-0.3.0}/LICENSE +0 -0
- {redenv-0.2.0 → redenv-0.3.0}/src/redenv/client.py +0 -0
- {redenv-0.2.0 → redenv-0.3.0}/src/redenv/crypto.py +0 -0
- {redenv-0.2.0 → redenv-0.3.0}/src/redenv/errors.py +0 -0
- {redenv-0.2.0 → redenv-0.3.0}/src/redenv/py.typed +0 -0
- {redenv-0.2.0 → redenv-0.3.0}/src/redenv/sync/__init__.py +0 -0
- {redenv-0.2.0 → redenv-0.3.0}/src/redenv/sync/client.py +0 -0
- {redenv-0.2.0 → redenv-0.3.0}/src/redenv/types.py +0 -0
- {redenv-0.2.0 → redenv-0.3.0}/tests/test_crypto.py +0 -0
|
@@ -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
|
|
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/
|
|
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.
|
|
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.
|
|
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
|
-
###
|
|
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.
|
|
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
|
-
###
|
|
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",
|
|
@@ -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
|
-
|
|
50
|
-
helpful error if the key is missing.
|
|
61
|
+
Returns None if the key is not found instead of raising KeyError.
|
|
51
62
|
"""
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
File without changes
|