fix: resolve secondary FKs in nested POST (event_badge_template_id)

In the nested POST handler (api_crud_v3_nested.py), sanitize_payload was
running before model instantiation. For secondary FK fields like
event_badge_template_id, sanitize_payload resolved the random string →
integer, then the model's root_validator stripped the integer back to None
(Vision ID anti-leakage guard). Only the parent FK survived because it was
explicitly re-injected after serialization.

Fix: moved sanitize_payload to run on data_to_insert after serialization,
matching the flat V3 POST pattern (api_crud_v3.py). Also moved account_id
injection to after sanitize_payload, fixing a latent bug where account_id
was silently written as NULL on non-bypass auth.

Adds regression test to test_e2e_v3_demo_parity.py that creates an
event_badge via nested POST with event_badge_template_id and verifies the
field is non-None in the response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-11 19:00:51 -04:00
parent 516865b7d8
commit 0ecc5a97d5
2 changed files with 103 additions and 11 deletions

View File

@@ -313,15 +313,6 @@ async def post_child_obj(
if not table_name_insert or not input_model or not table_name_select or not output_model: if not table_name_insert or not input_model or not table_name_select or not output_model:
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.") return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
if not account.super and account.auth_method != 'bypass' and account.account_id:
if 'account_id' in input_model.__fields__:
obj_data['account_id'] = account.account_id
obj_data[f'{parent_obj_type}_id'] = resolved_parent_id
# Sanitize payload (ID resolution, virtual fields, and optionally extra fields)
sanitize_payload(obj_data, input_model, ignore_extra=x_ae_ignore_extra_fields)
try: try:
validated_obj = input_model(**obj_data) validated_obj = input_model(**obj_data)
except ValidationError as e: except ValidationError as e:
@@ -332,8 +323,21 @@ async def post_child_obj(
data_to_insert = validated_obj.dict(exclude_unset=True) data_to_insert = validated_obj.dict(exclude_unset=True)
# Re-inject parent FK after model serialization. Some model root_validators strip # Sanitize AFTER serialization so that:
# integer IDs (a Vision ID anti-leakage guard) which would drop the FK from the dict. # 1. The model receives raw Vision ID strings (passes field-length constraints).
# 2. ID resolution (string → integer) happens on the dict going to the DB,
# avoiding the root_validator's integer-stripping anti-leakage guard.
# (Matches the flat V3 POST pattern in api_crud_v3.py.)
sanitize_payload(data_to_insert, input_model, ignore_extra=x_ae_ignore_extra_fields)
# Enforce account ownership AFTER sanitize_payload so the integer account_id goes
# straight to the DB without conflicting with Vision ID string constraints in the model.
if not account.super and account.auth_method != 'bypass' and account.account_id:
if 'account_id' in input_model.__fields__:
data_to_insert['account_id'] = account.account_id
# Re-inject parent FK last — overrides anything sanitize_payload or the model may have
# set — ensuring the child is always linked to the correct parent.
data_to_insert[f'{parent_obj_type}_id'] = resolved_parent_id data_to_insert[f'{parent_obj_type}_id'] = resolved_parent_id
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert): if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):

View File

@@ -10,6 +10,10 @@ API_KEY = "PMM4n50teUCaOMMTN8qOJA" # Agent API Key
# journal account: nqOzejLCDXM | event account: GpLf_bnywCs # journal account: nqOzejLCDXM | event account: GpLf_bnywCs
JOURNAL_PARENT_ID = "OGQK-02-04-94" JOURNAL_PARENT_ID = "OGQK-02-04-94"
EVENT_PARENT_ID = "vfzVJF0LH1O" EVENT_PARENT_ID = "vfzVJF0LH1O"
# event_person: ffkKxiHpOEC (16603) "Scott Idem" under Demo event
EVENT_PERSON_PARENT_ID = "ffkKxiHpOEC"
# event_badge_template: jgfixEpYp1B (18) "Dev Demo 202x"
EVENT_BADGE_TEMPLATE_ID = "jgfixEpYp1B"
# Test Targets: (Object Type, Valid ID Random) # Test Targets: (Object Type, Valid ID Random)
# Note: These IDs are extracted from real active records. # Note: These IDs are extracted from real active records.
@@ -127,6 +131,75 @@ def test_nested_create_lifecycle(parent_type, parent_id, child_type, payload):
return True return True
def test_nested_create_secondary_fk(parent_type, parent_id, child_type, payload, required_fk_fields):
"""
Regression test for secondary FK resolution in nested POST create.
Bug: sanitize_payload ran BEFORE model instantiation in the nested POST handler.
For FKs other than the parent FK (e.g. event_badge_template_id on event_badge),
sanitize_payload resolved the string → integer, then the model's root_validator
stripped the integer back to None (Vision ID anti-leakage guard). The parent FK
survived only because it was explicitly re-injected; secondary FKs were silently lost.
Fix (api_crud_v3_nested.py): moved sanitize_payload to run on data_to_insert AFTER
model serialization, matching the flat V3 POST pattern.
Verifies:
1. POST returns 200.
2. Each field in required_fk_fields is present AND non-None in the response.
3. All *_id fields are strings (Vision Standard).
4. Cleanup: DELETE the created record.
"""
label = f"Nested Secondary FK ({parent_type}/{child_type})"
print(f"\n--- Regression: {label} ---")
url = f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/"
headers = get_headers()
resp = requests.post(url, headers=headers, json=payload)
if resp.status_code != 200:
print(f" ❌ [FAIL] POST returned {resp.status_code}: {resp.text[:300]}")
return False
data = resp.json().get('data', {})
new_id = data.get('id') or data.get('obj_id_random')
if not new_id or not isinstance(new_id, str):
print(f" ❌ [FAIL] No string 'id' in response. Got: {data}")
return False
print(f" ✅ [PASS] Created {child_type} with id: {new_id}")
# Check required secondary FK fields are present and non-None
for field in required_fk_fields:
val = data.get(field)
if val is None:
print(f" ❌ [FAIL] Secondary FK '{field}' is None — was not saved to DB.")
# Still attempt cleanup
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
return False
if not isinstance(val, str):
print(f" ❌ [FAIL] Secondary FK '{field}' is {type(val).__name__} ({val}) — must be string (Vision Standard).")
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
return False
print(f" ✅ [PASS] Secondary FK '{field}' = {val}")
# Vision compliance: all *_id fields must be strings
for key, val in data.items():
if (key == 'id' or key.endswith('_id')) and not key.endswith('external_id'):
if val is not None and not isinstance(val, str):
print(f" ❌ [FAIL] Vision violation: {key} is {type(val).__name__} ({val})")
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
return False
print(f" ✅ [PASS] Vision Standard: all ID fields are strings.")
# Cleanup
del_resp = requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
if del_resp.status_code == 200:
print(f" ✅ [PASS] Cleanup: deleted {new_id}")
else:
print(f" ⚠️ [WARN] Cleanup failed ({del_resp.status_code}) — manual cleanup may be needed for {new_id}")
return True
def test_nested_alias_resolution(): def test_nested_alias_resolution():
""" """
Verifies that the 'entry' alias and nested resolution works for journals. Verifies that the 'entry' alias and nested resolution works for journals.
@@ -171,6 +244,21 @@ if __name__ == "__main__":
child_type='event_session', child_type='event_session',
payload={'name': '[e2e-test] nested create regression', 'enable': False}, payload={'name': '[e2e-test] nested create regression', 'enable': False},
)) ))
# Secondary FK regression: event_badge_template_id must survive nested POST
# (was silently dropped as NULL before the sanitize_payload order fix)
results.append(test_nested_create_secondary_fk(
parent_type='event_person',
parent_id=EVENT_PERSON_PARENT_ID,
child_type='event_badge',
payload={
'event_badge_template_id': EVENT_BADGE_TEMPLATE_ID,
'given_name': '[e2e-test]',
'family_name': 'secondary-fk-regression',
'enable': False,
'hide': True,
},
required_fk_fields=['event_badge_template_id'],
))
elapsed = time.time() - suite_start elapsed = time.time() - suite_start
if all(results): if all(results):