cnos-hashicorp 1.12.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.
- cnos_hashicorp-1.12.0/.gitignore +33 -0
- cnos_hashicorp-1.12.0/PKG-INFO +7 -0
- cnos_hashicorp-1.12.0/pyproject.toml +19 -0
- cnos_hashicorp-1.12.0/src/cnos_hashicorp/__init__.py +4 -0
- cnos_hashicorp-1.12.0/src/cnos_hashicorp/provider.py +235 -0
- cnos_hashicorp-1.12.0/tests/__init__.py +0 -0
- cnos_hashicorp-1.12.0/tests/test_provider.py +214 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
.coop/logs/
|
|
2
|
+
.coop/tmp/
|
|
3
|
+
node_modules/
|
|
4
|
+
dist/
|
|
5
|
+
coverage/
|
|
6
|
+
.artifacts/
|
|
7
|
+
.turbo/
|
|
8
|
+
.DS_Store
|
|
9
|
+
Thumbs.db
|
|
10
|
+
.idea/
|
|
11
|
+
.vscode/
|
|
12
|
+
*.log
|
|
13
|
+
.env
|
|
14
|
+
.env.local
|
|
15
|
+
.env.*.local
|
|
16
|
+
!.env.example
|
|
17
|
+
pnpm-debug.log*
|
|
18
|
+
|
|
19
|
+
# Claude Code worktrees and session scratch
|
|
20
|
+
.claude/
|
|
21
|
+
.tmp/
|
|
22
|
+
|
|
23
|
+
# Python bytecode
|
|
24
|
+
__pycache__/
|
|
25
|
+
*.pyc
|
|
26
|
+
*.pyo
|
|
27
|
+
*.pyd
|
|
28
|
+
.pytest_cache/
|
|
29
|
+
|
|
30
|
+
# Java build output
|
|
31
|
+
target/
|
|
32
|
+
*.class
|
|
33
|
+
*.jar
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cnos-hashicorp"
|
|
7
|
+
version = "1.12.0"
|
|
8
|
+
description = "CNOS HashiCorp Vault vault provider"
|
|
9
|
+
requires-python = ">=3.8"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"cnos>=1.12.0",
|
|
12
|
+
"hvac>=1.2.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.hatch.build.targets.wheel]
|
|
16
|
+
packages = ["src/cnos_hashicorp"]
|
|
17
|
+
|
|
18
|
+
[tool.pytest.ini_options]
|
|
19
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""HashiCorp Vault CNOS vault provider — mirrors Go's hashicorpvault.go."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from cnos.errors import CnosError
|
|
7
|
+
from cnos.types import (
|
|
8
|
+
SecretVaultProvider,
|
|
9
|
+
SecretVaultProviderFactory,
|
|
10
|
+
VaultAuthConfig,
|
|
11
|
+
VaultDefinition,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
PROVIDER_NAME = "hashicorp-vault"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _string_config(config: Optional[Dict[str, Any]], key: str) -> str:
|
|
18
|
+
if not config:
|
|
19
|
+
return ""
|
|
20
|
+
val = config.get(key, "")
|
|
21
|
+
return str(val).strip() if val else ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _first_string_config(config: Optional[Dict[str, Any]], *keys: str) -> str:
|
|
25
|
+
for key in keys:
|
|
26
|
+
val = _string_config(config, key)
|
|
27
|
+
if val:
|
|
28
|
+
return val
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_version(value: Any) -> int:
|
|
33
|
+
if isinstance(value, int) and value in (1, 2):
|
|
34
|
+
return value
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
stripped = value.strip()
|
|
37
|
+
if stripped in ("1", "kv-v1"):
|
|
38
|
+
return 1
|
|
39
|
+
if stripped in ("2", "kv-v2"):
|
|
40
|
+
return 2
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _unique_sorted(refs: List[str]) -> List[str]:
|
|
45
|
+
return sorted(set(refs))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _join_path(*segments: str) -> str:
|
|
49
|
+
parts = []
|
|
50
|
+
for seg in segments:
|
|
51
|
+
trimmed = seg.strip("/")
|
|
52
|
+
if trimmed:
|
|
53
|
+
parts.append(trimmed)
|
|
54
|
+
return "/".join(parts)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _ParsedRef:
|
|
58
|
+
def __init__(self, path: str, field: str, explicit_field: bool) -> None:
|
|
59
|
+
self.path = path
|
|
60
|
+
self.field = field
|
|
61
|
+
self.explicit_field = explicit_field
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_vault_ref(ref: str) -> _ParsedRef:
|
|
65
|
+
index = ref.rfind("#")
|
|
66
|
+
if index == -1:
|
|
67
|
+
return _ParsedRef(path=ref, field="value", explicit_field=False)
|
|
68
|
+
field = ref[index + 1:] or "value"
|
|
69
|
+
return _ParsedRef(path=ref[:index], field=field, explicit_field=True)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read_kv_data(data: Dict[str, Any], version: int) -> Optional[Dict[str, Any]]:
|
|
73
|
+
if version != 2:
|
|
74
|
+
return data
|
|
75
|
+
nested = data.get("data")
|
|
76
|
+
if isinstance(nested, dict):
|
|
77
|
+
return nested
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _decode_vault_value(
|
|
82
|
+
data: Optional[Dict[str, Any]], field: str, explicit_field: bool
|
|
83
|
+
) -> Optional[str]:
|
|
84
|
+
if data is None:
|
|
85
|
+
return None
|
|
86
|
+
val = data.get(field)
|
|
87
|
+
if val is not None:
|
|
88
|
+
s = _primitive_string(val)
|
|
89
|
+
if s is not None:
|
|
90
|
+
return s
|
|
91
|
+
if explicit_field:
|
|
92
|
+
return None
|
|
93
|
+
# Try to find a single primitive field
|
|
94
|
+
primitives = []
|
|
95
|
+
for v in data.values():
|
|
96
|
+
s = _primitive_string(v)
|
|
97
|
+
if s is not None:
|
|
98
|
+
primitives.append(s)
|
|
99
|
+
if len(primitives) == 1:
|
|
100
|
+
return primitives[0]
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _primitive_string(value: Any) -> Optional[str]:
|
|
105
|
+
if isinstance(value, str):
|
|
106
|
+
return value
|
|
107
|
+
if isinstance(value, bool):
|
|
108
|
+
return "true" if value else "false"
|
|
109
|
+
if isinstance(value, int):
|
|
110
|
+
return str(value)
|
|
111
|
+
if isinstance(value, float):
|
|
112
|
+
return str(value)
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class HashiCorpVaultProvider(SecretVaultProvider):
|
|
117
|
+
"""HashiCorp Vault KV provider. Supports injecting a mock client for tests."""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
vault_id: str,
|
|
122
|
+
definition: VaultDefinition,
|
|
123
|
+
client: Any = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
self._vault_id = vault_id
|
|
126
|
+
self._definition = definition
|
|
127
|
+
self._config = self._read_config(definition)
|
|
128
|
+
self._client = client
|
|
129
|
+
self._token: str = ""
|
|
130
|
+
self._authenticated = False
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _read_config(definition: VaultDefinition) -> Dict[str, Any]:
|
|
134
|
+
config = definition.auth.config or {}
|
|
135
|
+
version = _read_version(config.get("version"))
|
|
136
|
+
if version == 0:
|
|
137
|
+
version = 2
|
|
138
|
+
mount = _string_config(config, "mount") or "secret"
|
|
139
|
+
return {
|
|
140
|
+
"address": _first_string_config(config, "address", "endpoint", "url"),
|
|
141
|
+
"mount": mount,
|
|
142
|
+
"namespace": _string_config(config, "namespace"),
|
|
143
|
+
"version": version,
|
|
144
|
+
"path": _string_config(config, "path"),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def authenticate(self, auth: VaultAuthConfig) -> None:
|
|
148
|
+
if auth.method != "token" or not auth.token:
|
|
149
|
+
raise CnosError(
|
|
150
|
+
f'vault "{self._vault_id}" uses {PROVIDER_NAME} and requires token authentication'
|
|
151
|
+
)
|
|
152
|
+
self._token = auth.token
|
|
153
|
+
self._authenticated = True
|
|
154
|
+
if self._client is None:
|
|
155
|
+
self._client = self._build_client()
|
|
156
|
+
|
|
157
|
+
def _build_client(self) -> Any:
|
|
158
|
+
try:
|
|
159
|
+
import hvac # type: ignore[import]
|
|
160
|
+
except ImportError as exc:
|
|
161
|
+
raise CnosError(
|
|
162
|
+
"cnos-hashicorp: hvac is required. Install with: pip install hvac"
|
|
163
|
+
) from exc
|
|
164
|
+
kwargs: Dict[str, Any] = {}
|
|
165
|
+
if self._config["address"]:
|
|
166
|
+
kwargs["url"] = self._config["address"]
|
|
167
|
+
client = hvac.Client(**kwargs)
|
|
168
|
+
client.token = self._token
|
|
169
|
+
return client
|
|
170
|
+
|
|
171
|
+
def batch_get(self, refs: List[str]) -> Dict[str, Any]:
|
|
172
|
+
result: Dict[str, Any] = {}
|
|
173
|
+
for ref in _unique_sorted(refs):
|
|
174
|
+
val = self._get_one(ref)
|
|
175
|
+
if val is not None:
|
|
176
|
+
result[ref] = val
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
def get(self, ref: str) -> Optional[Any]:
|
|
180
|
+
return self._get_one(ref)
|
|
181
|
+
|
|
182
|
+
def _get_one(self, ref: str) -> Optional[str]:
|
|
183
|
+
external = self._external_ref(ref)
|
|
184
|
+
parsed = _parse_vault_ref(external)
|
|
185
|
+
read_path = self._read_path(parsed.path)
|
|
186
|
+
data, status = self._client_read(read_path)
|
|
187
|
+
if status == 404 or data is None:
|
|
188
|
+
return None
|
|
189
|
+
kv_data = _read_kv_data(data, self._config["version"])
|
|
190
|
+
return _decode_vault_value(kv_data, parsed.field, parsed.explicit_field)
|
|
191
|
+
|
|
192
|
+
def _client_read(self, path: str) -> Tuple[Optional[Dict[str, Any]], int]:
|
|
193
|
+
"""Returns (data, status_code). 404 on not-found."""
|
|
194
|
+
try:
|
|
195
|
+
data, status = self._client.read(
|
|
196
|
+
path,
|
|
197
|
+
self._token,
|
|
198
|
+
self._config["namespace"],
|
|
199
|
+
)
|
|
200
|
+
return data, status
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
if self._is_not_found(exc):
|
|
203
|
+
return None, 404
|
|
204
|
+
raise CnosError(f"cnos-hashicorp: read {path!r} failed: {exc}") from exc
|
|
205
|
+
|
|
206
|
+
def _read_path(self, path: str) -> str:
|
|
207
|
+
if self._config["version"] == 2:
|
|
208
|
+
return _join_path(self._config["mount"], "data", self._config["path"], path)
|
|
209
|
+
return _join_path(self._config["mount"], self._config["path"], path)
|
|
210
|
+
|
|
211
|
+
def _external_ref(self, ref: str) -> str:
|
|
212
|
+
mapping = self._definition.mapping or {}
|
|
213
|
+
for external, logical in mapping.items():
|
|
214
|
+
if logical == ref:
|
|
215
|
+
return external
|
|
216
|
+
return ref
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _is_not_found(exc: Exception) -> bool:
|
|
220
|
+
cls_name = type(exc).__name__
|
|
221
|
+
if "InvalidPath" in cls_name or "NotFound" in cls_name:
|
|
222
|
+
return True
|
|
223
|
+
status = getattr(exc, "status_code", None)
|
|
224
|
+
return status == 404
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def factory(client: Any = None) -> SecretVaultProviderFactory:
|
|
228
|
+
"""Return a SecretVaultProviderFactory for HashiCorp Vault.
|
|
229
|
+
|
|
230
|
+
Pass client= to inject a mock for tests.
|
|
231
|
+
"""
|
|
232
|
+
def create(vault_id: str, definition: VaultDefinition) -> HashiCorpVaultProvider:
|
|
233
|
+
return HashiCorpVaultProvider(vault_id, definition, client=client)
|
|
234
|
+
|
|
235
|
+
return SecretVaultProviderFactory(provider=PROVIDER_NAME, create=create)
|
|
File without changes
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Tests for the HashiCorp Vault provider."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from cnos.types import VaultAuthConfig, VaultAuthDefinition, VaultDefinition
|
|
9
|
+
from cnos_hashicorp.provider import (
|
|
10
|
+
HashiCorpVaultProvider,
|
|
11
|
+
PROVIDER_NAME,
|
|
12
|
+
_parse_vault_ref,
|
|
13
|
+
_decode_vault_value,
|
|
14
|
+
_join_path,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Mock client — matches the interface expected by provider._client_read
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
class MockHvacClient:
|
|
23
|
+
def __init__(self, kv_data: dict, not_found: set = None, version: int = 2):
|
|
24
|
+
self._kv_data = kv_data # path -> dict of fields
|
|
25
|
+
self._not_found = not_found or set()
|
|
26
|
+
self._version = version
|
|
27
|
+
self.token = ""
|
|
28
|
+
|
|
29
|
+
def read(self, path: str, token: str, namespace: str) -> Tuple[Optional[Dict], int]:
|
|
30
|
+
for key in self._not_found:
|
|
31
|
+
if key in path:
|
|
32
|
+
return None, 404
|
|
33
|
+
for key, val in self._kv_data.items():
|
|
34
|
+
if key in path:
|
|
35
|
+
if self._version == 2:
|
|
36
|
+
return {"data": val}, 200
|
|
37
|
+
return val, 200
|
|
38
|
+
return None, 404
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _make_provider(
|
|
42
|
+
kv_data: dict,
|
|
43
|
+
not_found: set = None,
|
|
44
|
+
mapping: dict = None,
|
|
45
|
+
version: int = 2,
|
|
46
|
+
kv_path: str = "",
|
|
47
|
+
) -> HashiCorpVaultProvider:
|
|
48
|
+
config: Dict[str, Any] = {"version": version}
|
|
49
|
+
if kv_path:
|
|
50
|
+
config["path"] = kv_path
|
|
51
|
+
definition = VaultDefinition(
|
|
52
|
+
provider=PROVIDER_NAME,
|
|
53
|
+
auth=VaultAuthDefinition(method="token", config=config),
|
|
54
|
+
mapping=mapping or {},
|
|
55
|
+
)
|
|
56
|
+
client = MockHvacClient(kv_data, not_found, version)
|
|
57
|
+
p = HashiCorpVaultProvider("test-vault", definition, client=client)
|
|
58
|
+
p._token = "test-token"
|
|
59
|
+
p._authenticated = True
|
|
60
|
+
return p
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Tests
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
class TestAuthenticate:
|
|
68
|
+
def test_token_accepted(self):
|
|
69
|
+
p = _make_provider({})
|
|
70
|
+
p._authenticated = False
|
|
71
|
+
p.authenticate(VaultAuthConfig(method="token", token="mytoken"))
|
|
72
|
+
assert p._authenticated
|
|
73
|
+
|
|
74
|
+
def test_wrong_method_raises(self):
|
|
75
|
+
p = _make_provider({})
|
|
76
|
+
p._authenticated = False
|
|
77
|
+
with pytest.raises(Exception, match="token authentication"):
|
|
78
|
+
p.authenticate(VaultAuthConfig(method="iam"))
|
|
79
|
+
|
|
80
|
+
def test_empty_token_raises(self):
|
|
81
|
+
p = _make_provider({})
|
|
82
|
+
p._authenticated = False
|
|
83
|
+
with pytest.raises(Exception, match="token authentication"):
|
|
84
|
+
p.authenticate(VaultAuthConfig(method="token", token=""))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TestParseVaultRef:
|
|
88
|
+
def test_no_field_defaults_to_value(self):
|
|
89
|
+
parsed = _parse_vault_ref("my/secret")
|
|
90
|
+
assert parsed.path == "my/secret"
|
|
91
|
+
assert parsed.field == "value"
|
|
92
|
+
assert parsed.explicit_field is False
|
|
93
|
+
|
|
94
|
+
def test_explicit_field(self):
|
|
95
|
+
parsed = _parse_vault_ref("my/secret#password")
|
|
96
|
+
assert parsed.path == "my/secret"
|
|
97
|
+
assert parsed.field == "password"
|
|
98
|
+
assert parsed.explicit_field is True
|
|
99
|
+
|
|
100
|
+
def test_empty_field_defaults_to_value(self):
|
|
101
|
+
parsed = _parse_vault_ref("my/secret#")
|
|
102
|
+
assert parsed.field == "value"
|
|
103
|
+
|
|
104
|
+
def test_uses_last_hash(self):
|
|
105
|
+
parsed = _parse_vault_ref("my#path/secret#field")
|
|
106
|
+
assert parsed.field == "field"
|
|
107
|
+
assert parsed.path == "my#path/secret"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestDecodeVaultValue:
|
|
111
|
+
def test_returns_named_field(self):
|
|
112
|
+
data = {"value": "secret", "other": "x"}
|
|
113
|
+
result = _decode_vault_value(data, "value", False)
|
|
114
|
+
assert result == "secret"
|
|
115
|
+
|
|
116
|
+
def test_explicit_field(self):
|
|
117
|
+
data = {"password": "hunter2"}
|
|
118
|
+
result = _decode_vault_value(data, "password", True)
|
|
119
|
+
assert result == "hunter2"
|
|
120
|
+
|
|
121
|
+
def test_single_primitive_fallback(self):
|
|
122
|
+
data = {"token": "abc123"}
|
|
123
|
+
result = _decode_vault_value(data, "value", False)
|
|
124
|
+
assert result == "abc123"
|
|
125
|
+
|
|
126
|
+
def test_multiple_fields_no_explicit_returns_none(self):
|
|
127
|
+
data = {"a": "1", "b": "2"}
|
|
128
|
+
result = _decode_vault_value(data, "value", False)
|
|
129
|
+
assert result is None
|
|
130
|
+
|
|
131
|
+
def test_none_data(self):
|
|
132
|
+
result = _decode_vault_value(None, "value", False)
|
|
133
|
+
assert result is None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestBatchGet:
|
|
137
|
+
def test_returns_secret(self):
|
|
138
|
+
p = _make_provider({"my-secret": {"value": "secret123"}})
|
|
139
|
+
result = p.batch_get(["my-secret"])
|
|
140
|
+
assert result == {"my-secret": "secret123"}
|
|
141
|
+
|
|
142
|
+
def test_not_found_excluded(self):
|
|
143
|
+
p = _make_provider({}, not_found={"missing"})
|
|
144
|
+
result = p.batch_get(["missing"])
|
|
145
|
+
assert "missing" not in result
|
|
146
|
+
|
|
147
|
+
def test_multiple_secrets(self):
|
|
148
|
+
p = _make_provider({"s1": {"value": "v1"}, "s2": {"value": "v2"}})
|
|
149
|
+
result = p.batch_get(["s1", "s2"])
|
|
150
|
+
assert result["s1"] == "v1"
|
|
151
|
+
assert result["s2"] == "v2"
|
|
152
|
+
|
|
153
|
+
def test_deduplicates(self):
|
|
154
|
+
p = _make_provider({"s1": {"value": "v1"}})
|
|
155
|
+
result = p.batch_get(["s1", "s1"])
|
|
156
|
+
assert result == {"s1": "v1"}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TestGet:
|
|
160
|
+
def test_existing(self):
|
|
161
|
+
p = _make_provider({"sec": {"value": "val"}})
|
|
162
|
+
assert p.get("sec") == "val"
|
|
163
|
+
|
|
164
|
+
def test_missing_returns_none(self):
|
|
165
|
+
p = _make_provider({}, not_found={"missing"})
|
|
166
|
+
assert p.get("missing") is None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TestKvVersion:
|
|
170
|
+
def test_v2_wraps_data(self):
|
|
171
|
+
p = _make_provider({"sec": {"value": "v2-secret"}}, version=2)
|
|
172
|
+
result = p.batch_get(["sec"])
|
|
173
|
+
assert result.get("sec") == "v2-secret"
|
|
174
|
+
|
|
175
|
+
def test_v1_no_wrapping(self):
|
|
176
|
+
p = _make_provider({"sec": {"value": "v1-secret"}}, version=1)
|
|
177
|
+
result = p.batch_get(["sec"])
|
|
178
|
+
assert result.get("sec") == "v1-secret"
|
|
179
|
+
|
|
180
|
+
def test_read_path_v2(self):
|
|
181
|
+
p = _make_provider({}, version=2)
|
|
182
|
+
path = p._read_path("my/secret")
|
|
183
|
+
assert "data" in path
|
|
184
|
+
|
|
185
|
+
def test_read_path_v1(self):
|
|
186
|
+
p = _make_provider({}, version=1)
|
|
187
|
+
path = p._read_path("my/secret")
|
|
188
|
+
assert "data" not in path
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TestMapping:
|
|
192
|
+
def test_external_mapping(self):
|
|
193
|
+
p = _make_provider(
|
|
194
|
+
{"external-path": {"value": "mapped!"}},
|
|
195
|
+
mapping={"external-path": "logical-name"},
|
|
196
|
+
)
|
|
197
|
+
result = p.batch_get(["logical-name"])
|
|
198
|
+
assert result.get("logical-name") == "mapped!"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class TestJoinPath:
|
|
202
|
+
def test_basic(self):
|
|
203
|
+
assert _join_path("a", "b", "c") == "a/b/c"
|
|
204
|
+
|
|
205
|
+
def test_strips_slashes(self):
|
|
206
|
+
assert _join_path("/a/", "/b/") == "a/b"
|
|
207
|
+
|
|
208
|
+
def test_empty_segments_skipped(self):
|
|
209
|
+
assert _join_path("a", "", "b") == "a/b"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class TestProviderName:
|
|
213
|
+
def test_constant(self):
|
|
214
|
+
assert PROVIDER_NAME == "hashicorp-vault"
|