Skip to content

Commit 84ffb55

Browse files
Copilotyyyyyyyan
andauthored
SEP-1807: Cover settings-override resolution and PATCH preservation (#1549)
Add regression coverage for nested-key resolution and PATCH credential preservation in the shared settings-override registry. Test-only changes guard against alias lookup failures and persisting mask literals instead of live credentials. - **Nested resolution:** Cover alias-only matches, unresolved keys, multi-level and case-insensitive mapping traversal, and canonical ancestor prefixes through `override_provenance_for_rows`, which replaced `override_keys_for_rows`. - **PATCH preservation:** Cover absent and recursively nested credential URLs, secret-valued dictionaries and lists, and reordered model collections with fallback item matching. Assert returned payloads, not private predicates. - **Coverage:** Add 16 cases across the two registry test files. The requested suite passes all 577 tests; registry coverage increases from 87.05% to 94.18%. Pre-commit passes. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: yyyyyyyan <24644216+yyyyyyyan@users.noreply.github.com>
1 parent 8c2e898 commit 84ffb55

2 files changed

Lines changed: 277 additions & 3 deletions

File tree

tests/app/core/settings_override/test_registry.py

Lines changed: 184 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@
1313
# You should have received a copy of the GNU Affero General Public License
1414
# along with this program. If not, see <https://www.gnu.org/licenses/>.
1515

16-
"""Tests for the HOT-classification registry."""
16+
"""Test settings-override classification, resolution, and PATCH preservation."""
1717

1818
from string import Template
1919
from typing import ClassVar
2020

2121
import pytest
22-
from pydantic import BaseModel, SecretStr
22+
from pydantic import BaseModel, HttpUrl, SecretStr
2323
from sqlmodel.ext.asyncio.session import AsyncSession
2424

2525
from app.core.alerts.config import AlertSettings
@@ -43,12 +43,15 @@
4343
MaterializerPurpose,
4444
nested_overridable_field_names,
4545
override_rows_for_key,
46+
preserve_credential_urls_in_model_payload,
4647
preserve_patch_credential_url_value,
48+
preserve_secrets_in_model_payload,
4749
ReloadClassification,
4850
resolve_nested_field_metadata,
4951
SECRET_STR_MASK,
5052
unwrap_secrets_for_storage,
5153
)
54+
from app.core.utils.fields import CredentialHttpUrl, redact_credential_url
5255
from app.core.utils.pydantic import field_with_metadata
5356
from app.inventory.config import InventorySettings
5457
from app.sep.config import SEPSettings
@@ -200,6 +203,60 @@ def test_preserve_patch_credential_url_value_for_materializer_payload() -> None:
200203
assert preserved["endpoint"] == current["endpoint"]
201204

202205

206+
class _CredentialUrlModel(BaseModel):
207+
"""Represent a model with an optional credential-bearing endpoint."""
208+
209+
endpoint: CredentialHttpUrl | None = HttpUrl.build(
210+
scheme="https",
211+
username="test-user",
212+
host="service.test",
213+
password="synthetic-test-value",
214+
)
215+
216+
217+
def test_preserve_credential_urls_with_none_current_leaf() -> None:
218+
"""Leave a masked URL unchanged when no live password exists to restore."""
219+
current = _CredentialUrlModel(endpoint=None)
220+
incoming = _CredentialUrlModel().model_dump(mode="json")
221+
222+
preserved = preserve_credential_urls_in_model_payload(
223+
_CredentialUrlModel, current, incoming
224+
)
225+
226+
assert preserved == incoming
227+
assert preserved is not incoming
228+
229+
230+
def test_preserve_patch_credential_url_value_recurses_into_nested_model() -> None:
231+
"""Restore a nested URL password while retaining an outer-field PATCH."""
232+
233+
class _ConnectionGroup(BaseModel):
234+
connection: _CredentialUrlModel
235+
label: str
236+
237+
class _ConnectionSettings(BaseModel):
238+
group: _ConnectionGroup
239+
240+
current = _ConnectionGroup(
241+
connection=_CredentialUrlModel(),
242+
label="original",
243+
)
244+
incoming = current.model_dump(mode="json")
245+
incoming["label"] = "updated"
246+
247+
preserved = preserve_patch_credential_url_value(
248+
_ConnectionSettings.model_fields["group"], current, incoming
249+
)
250+
251+
assert preserved == {
252+
"connection": {"endpoint": str(current.connection.endpoint)},
253+
"label": "updated",
254+
}
255+
assert incoming["connection"]["endpoint"] == redact_credential_url(
256+
str(current.connection.endpoint)
257+
)
258+
259+
203260
class _SecretLeafModel(BaseModel):
204261
"""Nested model with a scalar SecretStr leaf (PMM-shaped)."""
205262

@@ -229,6 +286,131 @@ class _DictSecretSettings(BaseModel):
229286
)
230287

231288

289+
def test_preserve_secrets_in_model_payload_with_secret_dict() -> None:
290+
"""Restore masked dictionary values without replacing an explicit new secret."""
291+
current = _DictSecretSettings()
292+
incoming = current.model_dump(mode="json")
293+
incoming["secrets"]["token"] = "replacement-token"
294+
295+
preserved = preserve_secrets_in_model_payload(
296+
_DictSecretSettings, current, incoming
297+
)
298+
299+
assert preserved == {
300+
"secrets": {
301+
"api_key": current.secrets["api_key"].get_secret_value(),
302+
"token": "replacement-token",
303+
}
304+
}
305+
assert incoming["secrets"]["api_key"] == SECRET_STR_MASK
306+
307+
308+
def test_preserve_secrets_in_model_payload_with_secret_list() -> None:
309+
"""Restore masked list elements by position and keep explicitly replaced secrets."""
310+
311+
class _ListOfSecretsSettings(BaseModel):
312+
tokens: list[SecretStr]
313+
314+
current = _ListOfSecretsSettings(
315+
tokens=[SecretStr("keep-first"), SecretStr("replace-second")]
316+
)
317+
incoming = current.model_dump(mode="json")
318+
incoming["tokens"][1] = "new-second"
319+
320+
preserved = preserve_secrets_in_model_payload(
321+
_ListOfSecretsSettings, current, incoming
322+
)
323+
324+
assert preserved == {"tokens": ["keep-first", "new-second"]}
325+
assert incoming["tokens"][0] == SECRET_STR_MASK
326+
327+
328+
@pytest.mark.parametrize(
329+
"order", [(2, 0, 1), (0, 2, 1)], ids=["last-first", "first-then-last"]
330+
)
331+
def test_preserve_secrets_in_model_payload_matches_reordered_models(
332+
order: tuple[int, ...],
333+
) -> None:
334+
"""Pair homogeneous model items by their public values rather than PATCH position."""
335+
336+
class _ListSecretSettings(BaseModel):
337+
items: list[_SecretLeafModel]
338+
339+
current = _ListSecretSettings(
340+
items=[
341+
_SecretLeafModel(api_key=SecretStr("secret-a"), label="a"),
342+
_SecretLeafModel(api_key=SecretStr("secret-b"), label="b"),
343+
_SecretLeafModel(api_key=SecretStr("secret-c"), label="c"),
344+
]
345+
)
346+
incoming = {
347+
"items": [current.items[index].model_dump(mode="json") for index in order]
348+
}
349+
350+
preserved = preserve_secrets_in_model_payload(
351+
_ListSecretSettings, current, incoming
352+
)
353+
354+
assert preserved == {
355+
"items": [
356+
{
357+
"api_key": current.items[index].api_key.get_secret_value(),
358+
"label": current.items[index].label,
359+
}
360+
for index in order
361+
]
362+
}
363+
364+
365+
@pytest.mark.parametrize(
366+
("order", "expected_secrets"),
367+
[
368+
((1,), ["secret-b", "secret-a", "secret-c"]),
369+
((2, 0), ["secret-c", "secret-a", "secret-b"]),
370+
],
371+
ids=["field-overlap-then-position", "sole-unused-item"],
372+
)
373+
def test_preserve_secrets_in_model_payload_with_unrecognized_discriminator(
374+
order: tuple[int, ...], expected_secrets: list[str]
375+
) -> None:
376+
"""Restore masks through fallback matching without reusing a stored item."""
377+
378+
class _ProviderSecretLeaf(_SecretLeafModel):
379+
provider: str = "external"
380+
381+
class _ListSecretSettings(BaseModel):
382+
items: list[_ProviderSecretLeaf]
383+
384+
current = _ListSecretSettings(
385+
items=[
386+
_ProviderSecretLeaf(api_key=SecretStr("secret-a"), label="a"),
387+
_ProviderSecretLeaf(api_key=SecretStr("secret-b"), label="b"),
388+
_ProviderSecretLeaf(api_key=SecretStr("secret-c"), label="c"),
389+
]
390+
)
391+
incoming_items = [
392+
current.items[index].model_dump(mode="json", exclude={"provider"})
393+
for index in order
394+
]
395+
# This discriminator matches no class name; omit labels to force fallback pairing.
396+
fallback = current.items[0].model_dump(mode="json", exclude={"label"})
397+
incoming_items.extend(fallback.copy() for _ in range(4 - len(incoming_items)))
398+
incoming = {"items": incoming_items}
399+
400+
preserved = preserve_secrets_in_model_payload(
401+
_ListSecretSettings, current, incoming
402+
)
403+
404+
assert preserved == {
405+
"items": [
406+
{**item, "api_key": secret}
407+
for item, secret in zip(
408+
incoming_items, [*expected_secrets, SECRET_STR_MASK], strict=True
409+
)
410+
]
411+
}
412+
413+
232414
def test_preserve_patch_secret_value_for_top_level_field() -> None:
233415
"""Assert a masked top-level SecretStr PATCH restores the stored secret."""
234416
field = _TopLevelSecretSettings.model_fields["TOKEN"]

tests/app/core/settings_override/test_registry_nested.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
from typing import ClassVar
2222

2323
import pytest
24-
from pydantic import BaseModel, SecretStr, ValidationError
24+
from pydantic import BaseModel, Field, SecretStr, ValidationError
25+
from sqlmodel.ext.asyncio.session import AsyncSession
2526

2627
from app.core.middleware.security_headers import SecurityHeadersOptions
2728
from app.core.settings_override.api.routes import _settings_response_from_field
@@ -40,6 +41,7 @@
4041
nested_overridable_field_names,
4142
NESTED_VALUE_MISSING,
4243
not_overridable_field,
44+
override_provenance_for_rows,
4345
ReloadClassification,
4446
rendered_leaf_keys,
4547
resolve_nested_field,
@@ -48,6 +50,10 @@
4850
)
4951
from app.sep.config import CookieOptions, SEPSettings
5052
from app.tasks.config import TasksSettings
53+
from tests.app.core.settings_override.conftest import (
54+
insert_override_row,
55+
TASKS_SETTINGS_TOKEN,
56+
)
5157

5258

5359
class _Leaf(BaseModel):
@@ -115,6 +121,26 @@ def test_resolve_field_in_model_missing_segment() -> None:
115121
assert _resolve_field_in_model(CookieOptions, "NOPE") is None
116122

117123

124+
@pytest.mark.parametrize("segment", ["External", "Incoming", "Outgoing"])
125+
def test_resolve_field_in_model_alias_only_match(segment: str) -> None:
126+
"""Resolve each alias kind independently of the canonical attribute name."""
127+
128+
class _AliasedModel(BaseModel):
129+
value: int = Field(
130+
default=1,
131+
alias="external",
132+
validation_alias="incoming",
133+
serialization_alias="outgoing",
134+
)
135+
136+
resolved = _resolve_field_in_model(_AliasedModel, segment)
137+
138+
assert resolved is not None
139+
canonical, field = resolved
140+
assert canonical == "value"
141+
assert field is _AliasedModel.model_fields["value"]
142+
143+
118144
def test_resolve_nested_field_single_level() -> None:
119145
"""A single-level key resolves to a one-segment chain and its leaf field."""
120146
resolved = resolve_nested_field(SEPSettings, "SESSION_REFRESH__MAX_AGE")
@@ -327,6 +353,33 @@ def test_resolve_nested_field_metadata_reflects_chain_not_overridable() -> None:
327353
assert locked_leaf.reload is ReloadClassification.NOT_OVERRIDABLE
328354

329355

356+
def test_resolve_nested_field_metadata_unknown_leaf_returns_none() -> None:
357+
"""Return no metadata for an unresolvable nested key."""
358+
assert resolve_nested_field_metadata(SEPSettings, "SESSION_REFRESH__BOGUS") is None
359+
360+
361+
@pytest.mark.asyncio
362+
async def test_override_provenance_for_nested_row_includes_all_prefixes(
363+
session: AsyncSession,
364+
) -> None:
365+
"""Report the stored key and every canonical ancestor of a nested override."""
366+
row = await insert_override_row(
367+
session,
368+
setting_class=TASKS_SETTINGS_TOKEN,
369+
key="security_headers__STRICT_TRANSPORT_SECURITY__MAX_AGE",
370+
value=3600,
371+
)
372+
373+
provenance = override_provenance_for_rows(TasksSettings, [row])
374+
375+
assert set(provenance) == {
376+
row.key,
377+
"SECURITY_HEADERS",
378+
"SECURITY_HEADERS__strict_transport_security",
379+
"SECURITY_HEADERS__strict_transport_security__max_age",
380+
}
381+
382+
330383
class _SecretLeafModel(BaseModel):
331384
"""Represent a submodel with a required ``SecretStr`` leaf for resolver tests."""
332385

@@ -358,6 +411,45 @@ class _OptionalIntermediateParent(BaseModel):
358411
NESTED: _OptionalIntermediate = nested_overridable_field(_OptionalIntermediate())
359412

360413

414+
def test_resolve_nested_value_unknown_leaf_raises_keyerror() -> None:
415+
"""Reject a key that cannot resolve to a nested field."""
416+
proxy = OverridableSettingsProxy(
417+
_OptionalIntermediateParent, setting_class=SEPSettings.__name__
418+
)
419+
420+
with pytest.raises(KeyError, match="NESTED__BOGUS"):
421+
resolve_nested_value(
422+
settings_cls=_OptionalIntermediateParent,
423+
proxy=proxy,
424+
key="NESTED__BOGUS",
425+
)
426+
427+
428+
@pytest.mark.parametrize(
429+
("inner_key", "leaf_key"),
430+
[("INNER", "DEEP"), ("inner", "deep")],
431+
ids=["exact-mapping-keys", "case-insensitive-mapping-keys"],
432+
)
433+
def test_resolve_nested_value_continues_through_mapping(
434+
inner_key: str, leaf_key: str
435+
) -> None:
436+
"""Traverse a mapping intermediate and read its child using canonical names."""
437+
proxy = OverridableSettingsProxy(
438+
_OptionalIntermediateParent, setting_class=SEPSettings.__name__
439+
)
440+
expected_value = 42
441+
proxy._set_snapshot({"NESTED": {inner_key: {leaf_key: expected_value}}})
442+
443+
field, value = resolve_nested_value(
444+
settings_cls=_OptionalIntermediateParent,
445+
proxy=proxy,
446+
key="NESTED__INNER__DEEP",
447+
)
448+
449+
assert value == expected_value
450+
assert field is _OptionalInner.model_fields["DEEP"]
451+
452+
361453
def test_resolve_nested_value_missing_mapping_segment_returns_sentinel() -> None:
362454
"""Return :data:`NESTED_VALUE_MISSING` for a dict snapshot missing a segment."""
363455
proxy = OverridableSettingsProxy(

0 commit comments

Comments
 (0)