v2026.05.18.4 — Push direction hotfix: actually-works edition (v2.3)¶
Hotfix for v2.2 (2026.05.18.3) and multiple latent bugs from v2.0+. If you ran any push Job in v2.0–v2.2 and saw "Created/Updated successfully" logs, your sync most likely did nothing on the FortiGate — this release fixes that.
The story¶
The user asked one question: "did you test the new code on the live FortiGate?" The honest answer was no, only unit tests with mocks.
Live testing surfaced a stack of bugs that mock-based unit tests can't catch:
Connector.update(uid=..., data=...)raisedTypeErroron every call — fortigate-api's signature isupdate(self, data)only; the uid lives inside data.MagicMock()accepts any kwargs silently, so unit tests passed. Bug present in 10 callsites across firewall and wireless target adapters since v2.0.channel: ["1", "6", "11"]rejected by FortiOS with HTTP 500. The correct shape ischannel: [{"chan": "1"}, {"chan": "6"}, ...]— list of objects, not flat list. Empirically probed against FortiOS v7.0.14.commentwith parentheses rejected as XSS (error -173: "The string contains XSS vulnerability characters"). Default wtp-profile comment used(N radios)— changed to[N radios].- No HTTP status checking on create/update responses. All FortiOS rejections (500 + error code) were silently dropped. DiffSync's "Created successfully" log message only describes its in-memory store, not the actual REST call result.
- Verification scripts used
.get(uid=...)which doesn't filter —Connector.get(**kwargs)popskwargs[self.uid](which for most endpoints is"name", not"uid"). The buggy call fetched ALL records and[0]picked an unrelated one as "the verified record." This made every "live validated" claim in v1.0–v2.2 a false positive for anything beyond bulk pull/load shape.
What changed¶
check_fortios_response() helper¶
New helper in utils/fortios.py that raises FortiOSAPIError on
non-200 responses, including the FortiOS status, error, and
cli_error fields plus a label identifying the failed call. All 17
create/update callsites now check status. Catching this in model code
means failures are loud instead of silent.
Channel format fix¶
_radio_payload() now builds the correct FortiOS schema:
# Pre-v2.3 (broken, silent HTTP 500):
payload["channel"] = ["1", "6", "11"]
# v2.3:
payload["channel"] = [{"chan": "1"}, {"chan": "6"}, {"chan": "11"}]
XSS-safe default comment¶
FortiGateRadioProfile.create() now uses brackets in the auto-generated
comment: Created from Nautobot via nautobot-ssot-fortinet sync [2 radios].
Verification scripts fixed¶
e2e_push_validate.py and e2e_push_wtp_profile.py now use the correct
.get(name=...) filter. Past verification logs that said "✓ FortiGate
has X" with X equal to the actual injected name were false positives —
re-validate any prior claims if you depend on them.
v2.2 wtp-profile create — confirmed working end-to-end¶
After v2.3 fixes, the live FWF-61E test passes:
[3/4] Verify on FortiGate side...
✓ FortiGate has wtp-profile 'e2e-wtp-test'
comment: 'Created from Nautobot via nautobot-ssot-fortinet sync [2 radios]'
radio-1 band: '802.11n,g-only'
radio-2 band: '802.11ac'
✓ Both radios populated — sibling aggregation worked end-to-end
(FortiOS normalizes 802.11ax-5G to 802.11ac on the FWF-61E because
that's the highest-band the hardware supports — hardware-appropriate.)
Tests¶
- 193 unit tests (was 188 in v2.2). +4 for
check_fortios_response, +1 regression guard usingMagicMock(spec=Connector)so theupdate(uid=...)bug can't recur.
Upgrade from v2026.05.18.3¶
pip install --upgrade nautobot-ssot-fortinet
nautobot-server collectstatic --no-input
sudo systemctl restart nautobot nautobot-worker
No new Jobs (still 5). No DiffSync attr changes. No schema changes.
If you ran push Jobs in v2.0–v2.2 and saw "Updated successfully" but
the FortiGate state didn't match what you expected — that was the
uid= bug. Re-run the pull Job to refresh Nautobot's view, compare with
your intended state, then re-run push — the writes will actually land now.
Reflection¶
Two lessons worth keeping:
MagicMockis a footgun for keyword-argument signature mismatches. UseMagicMock(spec=ConcreteClass)for any unit test where the kwargs you pass must match a real API's signature.- A passing verification test isn't validation if the verification call
itself is buggy. The
.get(uid=...)pattern matched the.delete(uid=...)pattern and was assumed correct by analogy — but the methods have different signatures (get(**kwargs)vsdelete(uid="", **kwargs)). Live, end-to-end, "look at the actual record on the device" tests would have caught this in v1.0.