mirror of
https://github.com/ClusterM/open-bamboo-networking.git
synced 2026-10-19 01:29:46 -05:00
tests: SSDP golden fixtures, push_status delta-merge spec, signing trailing-sibling lock (#114)
This commit is contained in:
1 parent
d6e0cb95ff
commit
f356b164a6
27 files changed
+899
-53
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# Fixtures are byte-for-byte recordings. Never let git convert line endings —
|
||||
# a checkout that rewrites them changes what the corpus tests.
|
||||
tests/fixtures/** -text
|
||||
+47
-21
@@ -778,17 +778,59 @@ if(OBN_BUILD_TESTS)
|
||||
target_link_libraries(state_persist_test PRIVATE ws2_32)
|
||||
endif()
|
||||
add_test(NAME state_persist COMMAND state_persist_test)
|
||||
endif()
|
||||
if(OBN_BUILD_TESTS AND NOT WIN32)
|
||||
|
||||
# Live test: skipped unless OBN_PRINTER_IP etc. are exported when ctest
|
||||
# runs. Links against the same library as the plugin so it can exercise
|
||||
# obn::Agent directly.
|
||||
# Hermetic unit tests for obn::signing — exercises key loading, canonical
|
||||
# JSON ordering, signature round-trip and envelope structure validation.
|
||||
# Generates ephemeral RSA keys at runtime; no on-disk secrets required.
|
||||
add_executable(signing_test tests/signing_test.cpp
|
||||
tests/config_stub.cpp
|
||||
src/json_lite.cpp
|
||||
src/log.cpp
|
||||
src/os_compat.cpp
|
||||
src/signing.cpp)
|
||||
target_include_directories(signing_test PRIVATE include ${OBN_GENERATED_DIR})
|
||||
target_compile_definitions(signing_test PRIVATE
|
||||
OBN_VERSION_STRING="${OBN_VERSION}"
|
||||
ABI_VERSION=${OBN_ABI_VERSION_HEX})
|
||||
target_link_libraries(signing_test PRIVATE OpenSSL::SSL OpenSSL::Crypto)
|
||||
if(WIN32)
|
||||
# os_compat.cpp holds the Winsock wrappers (same reason as state_persist)
|
||||
target_link_libraries(signing_test PRIVATE ws2_32)
|
||||
endif()
|
||||
add_test(NAME signing COMMAND signing_test)
|
||||
|
||||
# DOC-01 golden-fixture suite for the SSDP parser: plain main()+CHECK
|
||||
# (json_lite_test.cpp style), no test framework in the configure step.
|
||||
# Hermetic (no socket). WORKING_DIRECTORY is how fixtures resolve at
|
||||
# runtime — not a compile definition, because the workspace path contains
|
||||
# a space (D-08). src/json_lite.cpp is linked because the test parses the
|
||||
# emitted device JSON with obn::json::parse (not used by ssdp.cpp itself).
|
||||
add_executable(ssdp_listener_test tests/ssdp_listener_test.cpp
|
||||
src/json_lite.cpp
|
||||
src/log.cpp
|
||||
src/os_compat.cpp
|
||||
src/ssdp.cpp)
|
||||
target_include_directories(ssdp_listener_test PRIVATE include)
|
||||
if(WIN32)
|
||||
# log.cpp needs os_compat.cpp for strcasecmp_portable, and that
|
||||
# translation unit also holds the Winsock wrappers — mirror
|
||||
# state_persist above.
|
||||
target_link_libraries(ssdp_listener_test PRIVATE ws2_32)
|
||||
endif()
|
||||
add_test(NAME ssdp_listener COMMAND ssdp_listener_test)
|
||||
set_tests_properties(ssdp_listener PROPERTIES
|
||||
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures")
|
||||
endif()
|
||||
if(OBN_BUILD_TESTS AND NOT WIN32)
|
||||
|
||||
# Dev tool, NOT a test (D-07): live UDP :2021 sniffer moved verbatim
|
||||
# out of tests/ssdp_listener_test.cpp. Deliberately has no add_test —
|
||||
# it binds a real socket and must never run under ctest.
|
||||
add_executable(ssdp_sniffer tools/ssdp_sniffer/main.cpp
|
||||
src/log.cpp
|
||||
src/os_compat.cpp
|
||||
src/ssdp.cpp)
|
||||
target_include_directories(ssdp_sniffer PRIVATE include)
|
||||
|
||||
add_executable(ftps_live_test tests/ftps_live_test.cpp
|
||||
src/config.cpp
|
||||
@@ -930,22 +972,6 @@ if(OBN_BUILD_TESTS AND NOT WIN32)
|
||||
)
|
||||
add_test(NAME lan_live COMMAND lan_live_test)
|
||||
|
||||
# Hermetic unit tests for obn::signing — exercises key loading, canonical
|
||||
# JSON ordering, signature round-trip and envelope structure validation.
|
||||
# Generates ephemeral RSA keys at runtime; no on-disk secrets required.
|
||||
add_executable(signing_test tests/signing_test.cpp
|
||||
tests/config_stub.cpp
|
||||
src/json_lite.cpp
|
||||
src/log.cpp
|
||||
src/os_compat.cpp
|
||||
src/signing.cpp)
|
||||
target_include_directories(signing_test PRIVATE include ${OBN_GENERATED_DIR})
|
||||
target_compile_definitions(signing_test PRIVATE
|
||||
OBN_VERSION_STRING="${OBN_VERSION}"
|
||||
ABI_VERSION=${OBN_ABI_VERSION_HEX})
|
||||
target_link_libraries(signing_test PRIVATE OpenSSL::SSL OpenSSL::Crypto)
|
||||
add_test(NAME signing COMMAND signing_test)
|
||||
|
||||
add_executable(cert_store_test tests/cert_store_test.cpp
|
||||
tests/config_stub.cpp
|
||||
src/cert_store.cpp
|
||||
|
||||
@@ -339,6 +339,9 @@ static std::string json_str_escape(const std::string& s)
|
||||
return out;
|
||||
}
|
||||
|
||||
// Emits {"header":…,"<root_key>":dump} ONLY — trailing siblings in the input
|
||||
// payload are not carried. See the invariant comment at to_sign (maybe_sign)
|
||||
// below, locked by test_trailing_sibling_reconstruction in tests/signing_test.cpp.
|
||||
// Builds the complete signed envelope JSON string.
|
||||
std::string build_envelope(const std::string& to_sign,
|
||||
const std::string& sig_b64,
|
||||
@@ -388,6 +391,15 @@ std::string maybe_sign(const std::string& payload_json, EVP_PKEY* device_pub,
|
||||
build_command_dump(root_key, payload_json, device_pub, developer_mode);
|
||||
if (dump.empty()) return payload_json; // malformed; pass through
|
||||
|
||||
// SINGLE-ROOT WRAPPER INVARIANT (locked by test_trailing_sibling_reconstruction
|
||||
// in tests/signing_test.cpp): to_sign is exactly {"<root_key>":<dump>} —
|
||||
// family key first, single root, never sorted at the root level; the dump
|
||||
// here is byte-identical to the dump build_envelope emits (it receives the
|
||||
// same string), so payload_len == bytes signed == wire bytes.
|
||||
// A payload with trailing siblings (e.g. {"print":...,"user_id":...}) currently
|
||||
// DROPS the sibling — pinned, not fixed (research/10.04 Signing consensus;
|
||||
// diverges from the #72/farm consensus, which signs envelope-minus-header
|
||||
// including user_id).
|
||||
const std::string to_sign = "{\"" + root_key + "\":" + dump + "}";
|
||||
|
||||
const std::string sig_b64 = rsa_sha256_sign_b64(
|
||||
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Sean Callan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Vendored
+77
@@ -0,0 +1,77 @@
|
||||
# Vendored Fixtures — doomspork/PandaSpy (MIT)
|
||||
|
||||
These files are unmodified, verbatim copies of fixture recordings from
|
||||
[doomspork/PandaSpy](https://github.com/doomspork/PandaSpy), licensed under the
|
||||
MIT License — Copyright (c) 2026 Sean Callan.
|
||||
|
||||
- Source repo: https://github.com/doomspork/PandaSpy
|
||||
- License: MIT — full text vendored at [LICENSE.PandaSpy](LICENSE.PandaSpy)
|
||||
- Raw SSDP source: https://raw.githubusercontent.com/doomspork/PandaSpy/main/fixtures/ssdp/
|
||||
- Raw sequence source: https://raw.githubusercontent.com/doomspork/PandaSpy/main/fixtures/sequences/p1s-print-lifecycle/
|
||||
|
||||
## SHA-256 manifest
|
||||
|
||||
Every fixture byte is pinned here; the SSDP suite reads fixtures at runtime
|
||||
and fails loudly on a missing file (D-08), while this manifest guards
|
||||
against silent content edits. Regenerate with `sha256sum` over the two
|
||||
directories and diff against this block.
|
||||
|
||||
```
|
||||
# ssdp/
|
||||
d8c25b0be7ea457d8b56ea2537d6f0bb0c8b69e1a04be41892d25ff9bc846140 ssdp/synthetic-msearch-echo.txt
|
||||
3bef7752b26eb388d62c5bbcb7681f01e1be0658ca82e827b8e24bd5372253aa ssdp/synthetic-not-ssdp.txt
|
||||
fe3025744ef2654f80db90de2f782b54e107fb5856c8ab5ba7207fc6d3f23afe ssdp/synthetic-notify-colonless-header.txt
|
||||
cbe3587a31ab5ab8eb0cb0e6be713d1a7297ad53681eea05b96ec368fc91493a ssdp/synthetic-notify-https-location.txt
|
||||
7601dc2ac5d04cce68790f0a81ad32e3512c9720850eb613bf77ee7fd97785f7 ssdp/synthetic-notify-lf-lowercase-a1mini.txt
|
||||
b94b8b6553f7f586d1834a080e2f08f106b7a20a4f1ef58dbb1bcdd5fa934bb1 ssdp/synthetic-notify-names-nothing.txt
|
||||
5f39294f3abeb7bd623314b28f523dd931ad5071ec31eee95ccd400b49203a73 ssdp/synthetic-notify-no-location-h2d.txt
|
||||
b59b5e57a489d40ce1d96135baf5a526f155af2e34a3578a256916363864f916 ssdp/synthetic-notify-unknown-model.txt
|
||||
a1c82766646f95b9cb911130f9241fa336b6903e1d4726ded3ac37912328d530 ssdp/synthetic-notify-url-location.txt
|
||||
60581210ef84806f97f7d0d1e5cd43a69bd24c566063028565e488108f65c059 ssdp/synthetic-notify-x1c.txt
|
||||
0954b4e49067bf34083684feedf8e0009a28af3aba5d77b1c47e07d30601b2a6 ssdp/synthetic-search-response-404.txt
|
||||
4b674821d6d99b9f5001260dbb9466eb2475eb220e0f9b763a694aaadf2fadd2 ssdp/synthetic-search-response-p1s.txt
|
||||
# sequences/p1s-print-lifecycle/
|
||||
67a495afec3a7015eca2e22f8b80a81c92f5dd1a650c30f1d1649a1d6365a416 sequences/p1s-print-lifecycle/00-pushall.json
|
||||
acff29c28f7f20897f2c1078bd7b450e756dceb417f7aa76d8a02dd2ff9dcac2 sequences/p1s-print-lifecycle/01-delta-temps.json
|
||||
e5e6593bcb5dc73b3b0c3bd811baf87a4e42561d4c8e48a338fab19dd5682dad sequences/p1s-print-lifecycle/02-delta-progress.json
|
||||
e4253394710a98f75cd3c95addf7d2a142bb4df0b2ac98c165887215cc55673e sequences/p1s-print-lifecycle/03-delta-tray-switch.json
|
||||
d512d7fceabb9b89265f1e053856de3ea9101f6b3f4c3860df7e564469dce1e1 sequences/p1s-print-lifecycle/04-delta-null-clears-wifi.json
|
||||
cb33381a4026304cff97a7f0e0cec3e473b9b6de765fdfebf825f09a3d175eb6 sequences/p1s-print-lifecycle/05-delta-pause.json
|
||||
85c475686240bbc0616459535113e6e3cc98dce41d467de6670c464e8668622a sequences/p1s-print-lifecycle/06-delta-finish.json
|
||||
```
|
||||
|
||||
## SSDP inventory (12 files — live GitHub API listing of upstream `fixtures/ssdp/`, 2026-09-29)
|
||||
|
||||
- synthetic-msearch-echo.txt
|
||||
- synthetic-not-ssdp.txt
|
||||
- synthetic-notify-colonless-header.txt
|
||||
- synthetic-notify-https-location.txt
|
||||
- synthetic-notify-lf-lowercase-a1mini.txt
|
||||
- synthetic-notify-names-nothing.txt
|
||||
- synthetic-notify-no-location-h2d.txt
|
||||
- synthetic-notify-unknown-model.txt
|
||||
- synthetic-notify-url-location.txt
|
||||
- synthetic-notify-x1c.txt
|
||||
- synthetic-search-response-404.txt
|
||||
- synthetic-search-response-p1s.txt
|
||||
|
||||
Upstream serves **12** SSDP fixtures; our survey notes (INTERESTING_REPOS.md:53,
|
||||
decision D-02) said **13** — the 13-vs-12 count discrepancy is recorded here
|
||||
deliberately (OQ-2). No 13th fixture is ever invented.
|
||||
|
||||
## Sequence inventory (7 files, `p1s-print-lifecycle/`)
|
||||
|
||||
Raw material for a future `push_status` delta-merge test — the repo has no
|
||||
push_status accumulator to call yet, so no test drives them today (they are
|
||||
kept for the day one exists):
|
||||
|
||||
1. `00-pushall.json`
|
||||
2. `01-delta-temps.json`
|
||||
3. `02-delta-progress.json`
|
||||
4. `03-delta-tray-switch.json`
|
||||
5. `04-delta-null-clears-wifi.json`
|
||||
6. `05-delta-pause.json`
|
||||
7. `06-delta-finish.json`
|
||||
|
||||
All fixture bytes are guarded by `.gitattributes` (`tests/fixtures/** -text`)
|
||||
so git never converts line endings — the LF-only case stays LF-only.
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"print": {
|
||||
"command": "push_status",
|
||||
"msg": 0,
|
||||
"sequence_id": "1",
|
||||
"gcode_state": "RUNNING",
|
||||
"gcode_file": "benchy_plate_1.gcode",
|
||||
"subtask_name": "benchy",
|
||||
"print_type": "local",
|
||||
"stg_cur": 0,
|
||||
"mc_percent": 9,
|
||||
"mc_remaining_time": 118,
|
||||
"layer_num": 12,
|
||||
"total_layer_num": 137,
|
||||
"bed_temper": 59.8,
|
||||
"bed_target_temper": 60.0,
|
||||
"nozzle_temper": 219.4,
|
||||
"nozzle_target_temper": 220.0,
|
||||
"chamber_temper": 33.0,
|
||||
"cooling_fan_speed": "15",
|
||||
"big_fan1_speed": "0",
|
||||
"big_fan2_speed": "9",
|
||||
"heatbreak_fan_speed": "15",
|
||||
"spd_lvl": 2,
|
||||
"spd_mag": 100,
|
||||
"nozzle_diameter": "0.4",
|
||||
"nozzle_type": "stainless_steel",
|
||||
"wifi_signal": "-52dBm",
|
||||
"sdcard": true,
|
||||
"print_error": 0,
|
||||
"hms": [],
|
||||
"lights_report": [{ "node": "chamber_light", "mode": "on" }],
|
||||
"ams": {
|
||||
"ams_exist_bits": "1",
|
||||
"tray_exist_bits": "f",
|
||||
"tray_now": "1",
|
||||
"tray_pre": "255",
|
||||
"tray_tar": "1",
|
||||
"version": 402,
|
||||
"ams": [
|
||||
{
|
||||
"id": "0",
|
||||
"humidity": "3",
|
||||
"temp": "29.1",
|
||||
"tray": [
|
||||
{ "id": "0", "tray_type": "PLA", "tray_color": "FFFFFFFF", "remain": 71 },
|
||||
{ "id": "1", "tray_type": "PLA", "tray_color": "00AE42FF", "remain": 92 },
|
||||
{ "id": "2", "tray_type": "PETG", "tray_color": "0A2989FF", "remain": 34 },
|
||||
{ "id": "3", "tray_type": "TPU", "tray_color": "F5A623FF", "remain": -1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"vt_tray": { "id": "254", "tray_type": "", "tray_color": "00000000" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"print": {
|
||||
"command": "push_status",
|
||||
"msg": 1,
|
||||
"sequence_id": "2",
|
||||
"bed_temper": 60.1,
|
||||
"nozzle_temper": 220.2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"print": {
|
||||
"command": "push_status",
|
||||
"msg": 1,
|
||||
"sequence_id": "3",
|
||||
"mc_percent": 42,
|
||||
"mc_remaining_time": 96,
|
||||
"layer_num": 57
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"print": {
|
||||
"command": "push_status",
|
||||
"msg": 1,
|
||||
"sequence_id": "4",
|
||||
"stg_cur": 4,
|
||||
"ams": {
|
||||
"tray_now": "2",
|
||||
"tray_pre": "1",
|
||||
"tray_tar": "2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"print": {
|
||||
"command": "push_status",
|
||||
"msg": 1,
|
||||
"sequence_id": "5",
|
||||
"stg_cur": 0,
|
||||
"wifi_signal": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"print": {
|
||||
"command": "push_status",
|
||||
"msg": 1,
|
||||
"sequence_id": "6",
|
||||
"gcode_state": "PAUSE",
|
||||
"stg_cur": 16
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"print": {
|
||||
"command": "push_status",
|
||||
"msg": 1,
|
||||
"sequence_id": "7",
|
||||
"gcode_state": "FINISH",
|
||||
"stg_cur": -1,
|
||||
"mc_percent": 100,
|
||||
"mc_remaining_time": 0,
|
||||
"layer_num": 137,
|
||||
"nozzle_target_temper": 0.0,
|
||||
"bed_target_temper": 0.0,
|
||||
"cooling_fan_speed": "0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
M-SEARCH * HTTP/1.1
|
||||
HOST: 239.255.255.250:2021
|
||||
MAN: "ssdp:discover"
|
||||
MX: 3
|
||||
ST: urn:bambulab-com:device:3dprinter:1
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"service": "_bambulab._tcp.local",
|
||||
"port": 2021,
|
||||
"txt": ["provenance=synthetic", "note=chatter on the same socket, not SSDP"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
NOTIFY * HTTP/1.1
|
||||
HOST: 239.255.255.250:2021
|
||||
this-line-has-no-colon
|
||||
Location: 192.168.0.8
|
||||
USN: 01P00A000000000
|
||||
DevModel.bambu.com: C12
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
NOTIFY * HTTP/1.1
|
||||
HOST: 239.255.255.250:2021
|
||||
Location: https://192.168.0.11:8883/desc
|
||||
USN: 05A00A000000000
|
||||
DevModel.bambu.com: N2S
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
NOTIFY * HTTP/1.1
|
||||
host: 239.255.255.250:2021
|
||||
server: UPnP/1.0
|
||||
location: 192.168.0.4
|
||||
nt: urn:bambulab-com:device:3dprinter:1
|
||||
usn: 03900A000000000
|
||||
cache-control: max-age=1800
|
||||
devmodel.bambu.com: N1
|
||||
devname.bambu.com: REDACTED-NAME
|
||||
devsignal.bambu.com: -67
|
||||
devconnect.bambu.com: lan
|
||||
devbind.bambu.com: free
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
NOTIFY * HTTP/1.1
|
||||
HOST: 239.255.255.250:2021
|
||||
Location: 192.168.0.9
|
||||
NT: urn:bambulab-com:device:3dprinter:1
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
NOTIFY * HTTP/1.1
|
||||
HOST: 239.255.255.250:2021
|
||||
Server: UPnP/1.0
|
||||
NT: urn:bambulab-com:device:3dprinter:1
|
||||
USN: 09400A000000000
|
||||
Cache-Control: max-age=1800
|
||||
DevModel.bambu.com: O1D
|
||||
DevName.bambu.com: REDACTED-NAME
|
||||
DevSignal.bambu.com: -51
|
||||
DevConnect.bambu.com: lan
|
||||
DevBind.bambu.com: free
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
NOTIFY * HTTP/1.1
|
||||
HOST: 239.255.255.250:2021
|
||||
Server: UPnP/1.0
|
||||
Location: 192.168.0.9
|
||||
NT: urn:bambulab-com:device:3dprinter:1
|
||||
USN: 0XX00A000000000
|
||||
Cache-Control: max-age=1800
|
||||
DevModel.bambu.com: 3DPrinter-X9-Hyper
|
||||
DevName.bambu.com: REDACTED-NAME
|
||||
DevSignal.bambu.com: -39
|
||||
DevConnect.bambu.com: lan
|
||||
DevBind.bambu.com: free
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
NOTIFY * HTTP/1.1
|
||||
HOST: 239.255.255.250:2021
|
||||
Server: UPnP/1.0
|
||||
Location: http://192.168.0.7:8883/desc
|
||||
NT: urn:bambulab-com:device:3dprinter:1
|
||||
USN: 01S00A000000000
|
||||
Cache-Control: max-age=1800
|
||||
DevModel.bambu.com: C11
|
||||
DevName.bambu.com: REDACTED-NAME
|
||||
DevSignal.bambu.com: -62
|
||||
DevConnect.bambu.com: lan
|
||||
DevBind.bambu.com: free
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
NOTIFY * HTTP/1.1
|
||||
HOST: 239.255.255.250:2021
|
||||
Server: UPnP/1.0
|
||||
Location: 192.168.0.2
|
||||
NT: urn:bambulab-com:device:3dprinter:1
|
||||
USN: 00M09A000000000
|
||||
Cache-Control: max-age=1800
|
||||
DevModel.bambu.com: 3DPrinter-X1-Carbon
|
||||
DevName.bambu.com: REDACTED-NAME
|
||||
DevSignal.bambu.com: -44
|
||||
DevConnect.bambu.com: lan
|
||||
DevBind.bambu.com: free
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
HTTP/1.1 404 Not Found
|
||||
HOST: 239.255.255.250:2021
|
||||
Server: UPnP/1.0
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
HTTP/1.1 200 OK
|
||||
HOST: 239.255.255.250:2021
|
||||
Server: UPnP/1.0
|
||||
Location: 192.168.0.3
|
||||
ST: urn:bambulab-com:device:3dprinter:1
|
||||
USN: 01P00A000000000
|
||||
Cache-Control: max-age=1800
|
||||
DevModel.bambu.com: C12
|
||||
DevName.bambu.com: REDACTED-NAME
|
||||
DevSignal.bambu.com: -58
|
||||
DevConnect.bambu.com: lan
|
||||
DevBind.bambu.com: free
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// Tests for src/signing.cpp — classifier, envelope structure, and signature
|
||||
// verification using a freshly generated RSA test keypair.
|
||||
//
|
||||
// D-07 header note (requirement-letter-vs-reality deviation, recorded per the
|
||||
// Phase 2 precedent): this file follows the repo convention (int main() +
|
||||
// fail_count + CHECK), NOT Catch2 — REQUIREMENTS calls the new case a
|
||||
// "Catch2 test", but only ssdp_listener_test.cpp is actually Catch2 in this
|
||||
// tree; Catch2 conversion of this file is deferred (test-infrastructure work).
|
||||
|
||||
#include "obn/signing.hpp"
|
||||
#include "obn/config.hpp"
|
||||
@@ -19,7 +25,17 @@
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#ifdef _WIN32
|
||||
// MSVC port (Pitfall 3): no <unistd.h> — POSIX names are mapped to their
|
||||
// underscore-prefixed CRT equivalents below; POSIX branch stays untouched.
|
||||
#include <cstdlib>
|
||||
#include <io.h>
|
||||
#define close _close
|
||||
#define open _open
|
||||
#define unlink _unlink
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
#include <vector>
|
||||
|
||||
#define CHECK(cond) do { \
|
||||
@@ -525,6 +541,42 @@ namespace obn::config {
|
||||
std::string& test_dir();
|
||||
}
|
||||
|
||||
static int test_trailing_sibling_reconstruction()
|
||||
{
|
||||
// SIGN-01 / D-08: trailing sibling AFTER the root key.
|
||||
// PINNED OBSERVED BEHAVIOR: maybe_sign/build_envelope emit
|
||||
// {"header":…,"print":<dump>} only — the trailing sibling (user_id) is
|
||||
// silently dropped, which DIVERGES from the #72/farm consensus ("signature
|
||||
// covers the whole envelope minus header, including user_id"), annotated
|
||||
// in research/10.04 §Signing consensus. Pinned per the Phase 2 D-09
|
||||
// register and NOT fixed (PROJECT.md Out of Scope: no signing change).
|
||||
const std::string payload =
|
||||
R"({"print":{"command":"pause","sequence_id":"11"},"user_id":"u_42"})";
|
||||
const std::string env = obn::signing::maybe_sign(payload); // no device key → hermetic
|
||||
CHECK(env != payload); // signed, not passed through
|
||||
auto val = obn::json::parse(env);
|
||||
CHECK(val);
|
||||
|
||||
// (D-08.1) Byte-exact wire match: reconstruct the signed string FROM THE
|
||||
// EMITTED envelope — the wire bytes are the oracle, not the input bytes.
|
||||
std::string dump = val->find("print").dump();
|
||||
std::string to_sign = "{\"print\":" + dump + "}";
|
||||
double plen = val->find("header.payload_len").as_number();
|
||||
CHECK(static_cast<std::size_t>(plen) == to_sign.size());
|
||||
// Envelope must end with the literal tail: ,"print":<dump>}
|
||||
std::string tail = ",\"print\":" + dump + "}";
|
||||
CHECK(env.size() >= tail.size());
|
||||
CHECK(env.compare(env.size() - tail.size(), tail.size(), tail) == 0);
|
||||
|
||||
// (D-08.2) Signature round-trip against the wire-reconstructed signed string.
|
||||
CHECK(verify_b64_sig(to_sign, val->find("header.sign_string").as_string()));
|
||||
|
||||
// (D-08.3) Sibling pin: dropped trailing sibling is the observed outcome.
|
||||
CHECK(val->find("user_id").kind() == obn::json::Value::Kind::Null);
|
||||
CHECK(env.find("\"user_id\"") == std::string::npos);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
// Generate fresh RSA-2048 keypair for signing tests.
|
||||
@@ -532,10 +584,28 @@ int main()
|
||||
if (!g_test_key) { std::cerr << "EVP_RSA_gen failed\n"; return 1; }
|
||||
|
||||
// Write private key to a temp PEM file and point the config at it.
|
||||
#ifdef _WIN32
|
||||
// Per-run unique temp path — never a fixed predictable filename: the
|
||||
// XXXXXX template is uniquified per run, then created with _O_EXCL so
|
||||
// parallel/repeated runs cannot clobber each other's key files.
|
||||
char tmp_path[512];
|
||||
const char* tmp_dir = std::getenv("TEMP");
|
||||
std::snprintf(tmp_path, sizeof(tmp_path), "%s\\signing_test_XXXXXX",
|
||||
(tmp_dir && *tmp_dir) ? tmp_dir : ".");
|
||||
if (_mktemp_s(tmp_path, sizeof(tmp_path)) != 0) {
|
||||
std::cerr << "_mktemp_s failed\n";
|
||||
EVP_PKEY_free(g_test_key);
|
||||
return 1;
|
||||
}
|
||||
int fd = _open(tmp_path, _O_CREAT | _O_EXCL | _O_WRONLY, 0600);
|
||||
if (fd < 0) { std::cerr << "_open temp failed\n"; EVP_PKEY_free(g_test_key); return 1; }
|
||||
close(fd);
|
||||
#else
|
||||
char tmp_path[] = "/tmp/signing_test_XXXXXX";
|
||||
int fd = mkstemp(tmp_path);
|
||||
if (fd < 0) { std::cerr << "mkstemp failed\n"; EVP_PKEY_free(g_test_key); return 1; }
|
||||
close(fd);
|
||||
#endif
|
||||
std::string pem_path = std::string(tmp_path) + ".pem";
|
||||
|
||||
int pem_fd = open(pem_path.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
@@ -577,6 +647,7 @@ int main()
|
||||
if (test_param_enc_idempotent() != 0) rc = 1;
|
||||
if (test_blockwise_multiblock_roundtrip() != 0) rc = 1;
|
||||
if (test_liveview_prepare_ttcode_encrypted() != 0) rc = 1;
|
||||
if (test_trailing_sibling_reconstruction() != 0) rc = 1;
|
||||
|
||||
if (rc == 0) std::cout << "signing_test: ok\n";
|
||||
|
||||
|
||||
+394
-32
@@ -1,42 +1,404 @@
|
||||
// Standalone SSDP sniffer: binds to UDP :2021 and prints every Bambu-looking
|
||||
// JSON it builds from the incoming packets. Meant as a quick-feedback tool
|
||||
// while developing the parser; also doubles as a sanity check in CI when an
|
||||
// env-provided dummy broadcaster is available.
|
||||
// DOC-01 golden-fixture suite for the SSDP parser (D-05).
|
||||
//
|
||||
// Every vendored packet under tests/fixtures/ssdp/ (tests/fixtures/README.md
|
||||
// inventory, 12 files, D-02) is read at runtime, pushed through the
|
||||
// production obn::ssdp::parse, and the emitted device-info JSON is asserted
|
||||
// field by field (D-10 — never whole-string equality). D-11 coverage depth:
|
||||
// start-line acceptance + header bag + JSON for both spoof dialects.
|
||||
//
|
||||
// Style: plain main() + CHECK, same as json_lite_test.cpp — the rest of the
|
||||
// suite never pulls a test framework into the configure step.
|
||||
//
|
||||
// Scope: this suite locks parse() + to_device_info_json(), the bare parser.
|
||||
// It says nothing about what Studio receives: Discovery's recv loop drops
|
||||
// packets whose USN or DevModel is missing/empty (src/ssdp.cpp:337-339) and
|
||||
// fills the sender IP when Location is absent (src/ssdp.cpp:343-347), so the
|
||||
// empty-identity / empty-dev_ip outputs pinned below never reach the slicer
|
||||
// through the listener path. Where an observation differs from what the bare
|
||||
// parser does, the comment carries a `GAP` marker. No production behavior is
|
||||
// changed to satisfy a fixture — that is absolute this phase.
|
||||
//
|
||||
// The live UDP sniffer that used to live in this file was moved verbatim to
|
||||
// tools/ssdp_sniffer/main.cpp (D-05/D-07); it is not a test and is
|
||||
// deliberately unregistered in ctest.
|
||||
|
||||
#include "obn/json_lite.hpp"
|
||||
#include "obn/ssdp.hpp"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <mutex>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
int main(int argc, char** argv)
|
||||
static int fail_count = 0;
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, \
|
||||
#cond); \
|
||||
++fail_count; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
namespace {
|
||||
|
||||
// D-08: a missing or renamed fixture must fail loudly — print FAIL with
|
||||
// the absolute path (the ctest working directory is tests/fixtures, and
|
||||
// the workspace path contains a space) and count the failure so the
|
||||
// suite can never go green on a silent skip.
|
||||
bool read_fixture_impl(const char* rel, std::string& out, int line)
|
||||
{
|
||||
int port = 2021;
|
||||
int seconds = 15;
|
||||
if (argc > 1) seconds = std::atoi(argv[1]);
|
||||
if (argc > 2) port = std::atoi(argv[2]);
|
||||
|
||||
obn::ssdp::Discovery d;
|
||||
std::atomic<int> count{0};
|
||||
std::mutex stdout_mu;
|
||||
|
||||
if (!d.start(port, [&](std::string json) {
|
||||
std::lock_guard<std::mutex> lk(stdout_mu);
|
||||
std::printf("[ssdp] %s\n", json.c_str());
|
||||
++count;
|
||||
}))
|
||||
{
|
||||
std::fprintf(stderr, "ssdp start on :%d failed\n", port);
|
||||
return 1;
|
||||
std::ifstream in(rel, std::ios::binary);
|
||||
if (!in) {
|
||||
std::error_code ec;
|
||||
const std::string abs = std::filesystem::absolute(rel, ec).string();
|
||||
std::fprintf(stderr, "FAIL %s:%d: cannot open fixture: %s (absolute path: %s)\n",
|
||||
__FILE__, line, rel, abs.c_str());
|
||||
++fail_count;
|
||||
out.clear();
|
||||
return false;
|
||||
}
|
||||
std::ostringstream ss;
|
||||
ss << in.rdbuf();
|
||||
out = ss.str();
|
||||
return true;
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::seconds(seconds));
|
||||
d.stop();
|
||||
std::printf("received %d printer advertisements in %ds on :%d\n",
|
||||
count.load(), seconds, port);
|
||||
return count.load() > 0 ? 0 : 2;
|
||||
// Shared post-parse step: to_device_info_json -> obn::json::parse.
|
||||
bool device_doc(const obn::ssdp::Headers& h, std::string& storage,
|
||||
obn::json::Value& out)
|
||||
{
|
||||
storage = obn::ssdp::to_device_info_json(h);
|
||||
auto doc = obn::json::parse(storage);
|
||||
if (!doc.has_value()) {
|
||||
std::fprintf(stderr, "FAIL %s: emitted device JSON does not parse\n", __FILE__);
|
||||
++fail_count;
|
||||
return false;
|
||||
}
|
||||
out = *doc;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Call sites keep the read_fixture(rel, out) shape the plan specifies;
|
||||
// the line number for the loud-failure message comes from the call site.
|
||||
#define read_fixture(rel, out) read_fixture_impl(rel, out, __LINE__)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// DOC-01 mandated behaviors
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// NOTIFY + NT dialect (D-11): start line accepted, NT survives verbatim,
|
||||
// field-level JSON emitted from the header bag.
|
||||
static void test_notify_nt_golden()
|
||||
{
|
||||
std::string buf;
|
||||
if (!read_fixture("ssdp/synthetic-notify-x1c.txt", buf)) return;
|
||||
|
||||
obn::ssdp::Headers h;
|
||||
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
|
||||
|
||||
// Header bag: NOTIFY dialect, NT must survive verbatim.
|
||||
CHECK(h.value("nt") == "urn:bambulab-com:device:3dprinter:1");
|
||||
CHECK(h.value("location") == "192.168.0.2");
|
||||
|
||||
// Field-level JSON checks (D-10). Pinned to the values this fixture
|
||||
// actually carries — the plan quoted the colonless fixture's values
|
||||
// for the tracer, pin-observed per D-12.
|
||||
std::string json;
|
||||
obn::json::Value doc;
|
||||
if (!device_doc(h, json, doc)) return;
|
||||
CHECK(doc.find("dev_id").as_string() == "00M09A000000000");
|
||||
CHECK(doc.find("dev_ip").as_string() == "192.168.0.2");
|
||||
CHECK(doc.find("dev_type").as_string() == "3DPrinter-X1-Carbon");
|
||||
CHECK(doc.find("dev_name").as_string() == "REDACTED-NAME");
|
||||
CHECK(doc.find("connect_type").as_string() == "lan");
|
||||
CHECK(doc.find("bind_state").as_string() == "free");
|
||||
}
|
||||
|
||||
// HTTP/1.1 200 OK + ST dialect (D-11): response start line accepted, ST
|
||||
// captured into the header bag, plus field-level JSON.
|
||||
static void test_search_response_st_dialect()
|
||||
{
|
||||
std::string buf;
|
||||
if (!read_fixture("ssdp/synthetic-search-response-p1s.txt", buf)) return;
|
||||
|
||||
obn::ssdp::Headers h;
|
||||
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h)); // start line accepted
|
||||
|
||||
CHECK(h.value("st") == "urn:bambulab-com:device:3dprinter:1");
|
||||
CHECK(h.value("location") == "192.168.0.3");
|
||||
|
||||
std::string json;
|
||||
obn::json::Value doc;
|
||||
if (!device_doc(h, json, doc)) return;
|
||||
CHECK(doc.find("dev_id").as_string() == "01P00A000000000");
|
||||
CHECK(doc.find("dev_ip").as_string() == "192.168.0.3");
|
||||
CHECK(doc.find("dev_type").as_string() == "C12");
|
||||
CHECK(doc.find("dev_name").as_string() == "REDACTED-NAME");
|
||||
CHECK(doc.find("connect_type").as_string() == "lan");
|
||||
CHECK(doc.find("bind_state").as_string() == "free");
|
||||
}
|
||||
|
||||
// LF-only line endings + lowercase header names (DOC-01 mandated): keys are
|
||||
// lowercased on ingest anyway (obn::ssdp::Headers contract), values verbatim.
|
||||
static void test_lf_lowercase_headers()
|
||||
{
|
||||
std::string buf;
|
||||
if (!read_fixture("ssdp/synthetic-notify-lf-lowercase-a1mini.txt", buf)) return;
|
||||
CHECK(buf.find("\r") == std::string::npos); // fixture is genuinely LF-only
|
||||
|
||||
obn::ssdp::Headers h;
|
||||
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
|
||||
|
||||
CHECK(h.value("host") == "239.255.255.250:2021");
|
||||
CHECK(h.value("nt") == "urn:bambulab-com:device:3dprinter:1");
|
||||
CHECK(h.value("location") == "192.168.0.4");
|
||||
|
||||
std::string json;
|
||||
obn::json::Value doc;
|
||||
if (!device_doc(h, json, doc)) return;
|
||||
CHECK(doc.find("dev_id").as_string() == "03900A000000000");
|
||||
CHECK(doc.find("dev_ip").as_string() == "192.168.0.4");
|
||||
CHECK(doc.find("dev_type").as_string() == "N1");
|
||||
CHECK(doc.find("dev_name").as_string() == "REDACTED-NAME");
|
||||
CHECK(doc.find("connect_type").as_string() == "lan");
|
||||
CHECK(doc.find("bind_state").as_string() == "free");
|
||||
}
|
||||
|
||||
// Colonless header line (DOC-01 mandated): the line is dropped from the
|
||||
// bag (src/ssdp.cpp:133 skips lines without ':'), every other header
|
||||
// still parses.
|
||||
static void test_colonless_line_dropped()
|
||||
{
|
||||
std::string buf;
|
||||
if (!read_fixture("ssdp/synthetic-notify-colonless-header.txt", buf)) return;
|
||||
|
||||
obn::ssdp::Headers h;
|
||||
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
|
||||
|
||||
// Dropped at src/ssdp.cpp:133 — absent from the bag, not "".
|
||||
CHECK(h.get("this-line-has-no-colon") == nullptr);
|
||||
|
||||
// Surrounding headers survive.
|
||||
CHECK(h.value("host") == "239.255.255.250:2021");
|
||||
CHECK(h.value("location") == "192.168.0.8");
|
||||
CHECK(h.value("usn") == "01P00A000000000");
|
||||
CHECK(h.value("devmodel.bambu.com") == "C12");
|
||||
|
||||
std::string json;
|
||||
obn::json::Value doc;
|
||||
if (!device_doc(h, json, doc)) return;
|
||||
CHECK(doc.find("dev_id").as_string() == "01P00A000000000");
|
||||
CHECK(doc.find("dev_ip").as_string() == "192.168.0.8");
|
||||
CHECK(doc.find("dev_type").as_string() == "C12");
|
||||
}
|
||||
|
||||
// No Location header (DOC-01 mandated): the bare parser emits dev_ip="".
|
||||
static void test_no_location_parser_dev_ip_empty()
|
||||
{
|
||||
std::string buf;
|
||||
if (!read_fixture("ssdp/synthetic-notify-no-location-h2d.txt", buf)) return;
|
||||
|
||||
obn::ssdp::Headers h;
|
||||
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
|
||||
|
||||
CHECK(h.get("location") == nullptr);
|
||||
CHECK(h.value("nt") == "urn:bambulab-com:device:3dprinter:1");
|
||||
|
||||
std::string json;
|
||||
obn::json::Value doc;
|
||||
if (!device_doc(h, json, doc)) return;
|
||||
// GAP (bare parser only): with no Location header, dev_ip is "" — the
|
||||
// sender-address fallback lives in Discovery's recv loop
|
||||
// (src/ssdp.cpp:343-347) and is unreachable from the pure
|
||||
// parse/to_device_info_json API. The listener fills the real source IP
|
||||
// before anything reaches Studio, so this pins the parser, not the
|
||||
// slicer (D-09, Pitfall 9). PandaSpy fills the sender IP the same way.
|
||||
CHECK(doc.find("dev_ip").as_string() == "");
|
||||
CHECK(doc.find("dev_id").as_string() == "09400A000000000");
|
||||
CHECK(doc.find("dev_type").as_string() == "O1D");
|
||||
CHECK(doc.find("dev_name").as_string() == "REDACTED-NAME");
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Non-mandated extras — pinned observed behavior (D-12: pin now + annotate,
|
||||
// never fix)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// M-SEARCH probe echoed on the socket: accepted (start line has HTTP/1.),
|
||||
// but carries no USN/Location/DevModel, so the emitted device JSON is all
|
||||
// empty strings.
|
||||
static void test_msearch_probe_accepted()
|
||||
{
|
||||
std::string buf;
|
||||
if (!read_fixture("ssdp/synthetic-msearch-echo.txt", buf)) return;
|
||||
|
||||
obn::ssdp::Headers h;
|
||||
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
|
||||
|
||||
CHECK(h.value("st") == "urn:bambulab-com:device:3dprinter:1");
|
||||
CHECK(h.value("man") == "\"ssdp:discover\"");
|
||||
|
||||
std::string json;
|
||||
obn::json::Value doc;
|
||||
if (!device_doc(h, json, doc)) return;
|
||||
// GAP (bare parser only): a discovery probe (not an advertisement) still
|
||||
// yields a Studio-shaped device JSON with empty identity fields — pinned
|
||||
// as-is (D-12). The listener path never forwards it: Discovery drops
|
||||
// packets without USN/DevModel (src/ssdp.cpp:337-339).
|
||||
CHECK(doc.find("dev_id").as_string() == "");
|
||||
CHECK(doc.find("dev_ip").as_string() == "");
|
||||
CHECK(doc.find("dev_type").as_string() == "");
|
||||
}
|
||||
|
||||
// Non-SSDP chatter on UDP :2021: no HTTP/1. in the start line -> rejected.
|
||||
static void test_non_ssdp_rejected()
|
||||
{
|
||||
std::string buf;
|
||||
if (!read_fixture("ssdp/synthetic-not-ssdp.txt", buf)) return;
|
||||
|
||||
obn::ssdp::Headers h;
|
||||
CHECK(!obn::ssdp::parse(buf.data(), buf.size(), h)); // start line gate, src/ssdp.cpp:121
|
||||
}
|
||||
|
||||
// https:// Location: accepted and passed through to dev_ip verbatim.
|
||||
static void test_https_location_verbatim()
|
||||
{
|
||||
std::string buf;
|
||||
if (!read_fixture("ssdp/synthetic-notify-https-location.txt", buf)) return;
|
||||
|
||||
obn::ssdp::Headers h;
|
||||
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
|
||||
CHECK(h.value("location") == "https://192.168.0.11:8883/desc");
|
||||
|
||||
std::string json;
|
||||
obn::json::Value doc;
|
||||
if (!device_doc(h, json, doc)) return;
|
||||
// GAP: dev_ip is the raw Location string, not an extracted host — the
|
||||
// parser never URL-parses Location (src/ssdp.cpp:154). Studio receives
|
||||
// "https://192.168.0.11:8883/desc" where it expects a bare IP; pinned
|
||||
// as-is (D-12). (Location IS present, so the Discovery source-IP
|
||||
// fallback does not apply here.)
|
||||
CHECK(doc.find("dev_ip").as_string() == "https://192.168.0.11:8883/desc");
|
||||
CHECK(doc.find("dev_id").as_string() == "05A00A000000000");
|
||||
CHECK(doc.find("dev_type").as_string() == "N2S");
|
||||
// No DevName header in this packet.
|
||||
CHECK(doc.find("dev_name").as_string() == "");
|
||||
}
|
||||
|
||||
// http:// URL Location with port: same verbatim pass-through as above.
|
||||
static void test_url_location_verbatim()
|
||||
{
|
||||
std::string buf;
|
||||
if (!read_fixture("ssdp/synthetic-notify-url-location.txt", buf)) return;
|
||||
|
||||
obn::ssdp::Headers h;
|
||||
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
|
||||
|
||||
std::string json;
|
||||
obn::json::Value doc;
|
||||
if (!device_doc(h, json, doc)) return;
|
||||
// GAP: raw URL lands in dev_ip (see https-location case) — pinned, not
|
||||
// fixed (D-12).
|
||||
CHECK(doc.find("dev_ip").as_string() == "http://192.168.0.7:8883/desc");
|
||||
CHECK(doc.find("dev_id").as_string() == "01S00A000000000");
|
||||
CHECK(doc.find("dev_type").as_string() == "C11");
|
||||
CHECK(doc.find("dev_name").as_string() == "REDACTED-NAME");
|
||||
}
|
||||
|
||||
// Advertisement with NT+Location but no USN/DevModel/DevName at all.
|
||||
static void test_missing_identity_headers()
|
||||
{
|
||||
std::string buf;
|
||||
if (!read_fixture("ssdp/synthetic-notify-names-nothing.txt", buf)) return;
|
||||
|
||||
obn::ssdp::Headers h;
|
||||
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
|
||||
CHECK(h.value("nt") == "urn:bambulab-com:device:3dprinter:1");
|
||||
|
||||
std::string json;
|
||||
obn::json::Value doc;
|
||||
if (!device_doc(h, json, doc)) return;
|
||||
// GAP (bare parser only): an advertisement with no USN/model/name still
|
||||
// emits a complete device JSON with empty identity instead of being
|
||||
// dropped — pinned as-is (D-12). Discovery filters it out before the
|
||||
// callback (src/ssdp.cpp:337-339), so Studio never keys on "".
|
||||
CHECK(doc.find("dev_id").as_string() == "");
|
||||
CHECK(doc.find("dev_ip").as_string() == "192.168.0.9");
|
||||
CHECK(doc.find("dev_type").as_string() == "");
|
||||
CHECK(doc.find("dev_name").as_string() == "");
|
||||
}
|
||||
|
||||
// Unknown model string: parser is model-agnostic, passes it through.
|
||||
static void test_unknown_model_passthrough()
|
||||
{
|
||||
std::string buf;
|
||||
if (!read_fixture("ssdp/synthetic-notify-unknown-model.txt", buf)) return;
|
||||
|
||||
obn::ssdp::Headers h;
|
||||
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
|
||||
|
||||
std::string json;
|
||||
obn::json::Value doc;
|
||||
if (!device_doc(h, json, doc)) return;
|
||||
// GAP: no model validation — "3DPrinter-X9-Hyper" passes the listener's
|
||||
// non-empty DevModel check and reaches Studio verbatim with no
|
||||
// unknown-model flag (D-12 pin).
|
||||
CHECK(doc.find("dev_type").as_string() == "3DPrinter-X9-Hyper");
|
||||
CHECK(doc.find("dev_id").as_string() == "0XX00A000000000");
|
||||
CHECK(doc.find("dev_ip").as_string() == "192.168.0.9");
|
||||
CHECK(doc.find("dev_name").as_string() == "REDACTED-NAME");
|
||||
}
|
||||
|
||||
// HTTP error response. Pitfall 10: the start-line gate is a substring
|
||||
// check for "HTTP/1." (src/ssdp.cpp:121), so a 404 is ACCEPTED.
|
||||
static void test_404_response_accepted()
|
||||
{
|
||||
std::string buf;
|
||||
if (!read_fixture("ssdp/synthetic-search-response-404.txt", buf)) return;
|
||||
|
||||
obn::ssdp::Headers h;
|
||||
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h)); // accepted, not rejected
|
||||
CHECK(h.value("host") == "239.255.255.250:2021");
|
||||
|
||||
std::string json;
|
||||
obn::json::Value doc;
|
||||
if (!device_doc(h, json, doc)) return;
|
||||
// GAP (bare parser only): an HTTP error response parses and emits
|
||||
// empty-identity device JSON instead of being rejected — pin accepted
|
||||
// (src/ssdp.cpp:121, Pitfall 10, D-12). This fixture carries no
|
||||
// USN/DevModel, so the listener drops it at src/ssdp.cpp:337-339 and
|
||||
// nothing reaches Studio.
|
||||
CHECK(doc.find("dev_id").as_string() == "");
|
||||
CHECK(doc.find("dev_ip").as_string() == "");
|
||||
CHECK(doc.find("dev_type").as_string() == "");
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
test_notify_nt_golden();
|
||||
test_search_response_st_dialect();
|
||||
test_lf_lowercase_headers();
|
||||
test_colonless_line_dropped();
|
||||
test_no_location_parser_dev_ip_empty();
|
||||
test_msearch_probe_accepted();
|
||||
test_non_ssdp_rejected();
|
||||
test_https_location_verbatim();
|
||||
test_url_location_verbatim();
|
||||
test_missing_identity_headers();
|
||||
test_unknown_model_passthrough();
|
||||
test_404_response_accepted();
|
||||
|
||||
// D-08 guard: read_fixture increments fail_count and prints an absolute
|
||||
// path on a missing/renamed file; any non-zero count means the suite
|
||||
// would otherwise report success without executing assertions.
|
||||
CHECK(fail_count == 0);
|
||||
|
||||
if (fail_count == 0)
|
||||
std::printf("ssdp_listener_test: ok\n");
|
||||
return fail_count == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Standalone SSDP sniffer: binds to UDP :2021 and prints every Bambu-looking
|
||||
// JSON it builds from the incoming packets. Meant as a quick-feedback tool
|
||||
// while developing the parser; also doubles as a sanity check in CI when an
|
||||
// env-provided dummy broadcaster is available.
|
||||
|
||||
#include "obn/ssdp.hpp"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
int port = 2021;
|
||||
int seconds = 15;
|
||||
if (argc > 1) seconds = std::atoi(argv[1]);
|
||||
if (argc > 2) port = std::atoi(argv[2]);
|
||||
|
||||
obn::ssdp::Discovery d;
|
||||
std::atomic<int> count{0};
|
||||
std::mutex stdout_mu;
|
||||
|
||||
if (!d.start(port, [&](std::string json) {
|
||||
std::lock_guard<std::mutex> lk(stdout_mu);
|
||||
std::printf("[ssdp] %s\n", json.c_str());
|
||||
++count;
|
||||
}))
|
||||
{
|
||||
std::fprintf(stderr, "ssdp start on :%d failed\n", port);
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::seconds(seconds));
|
||||
d.stop();
|
||||
std::printf("received %d printer advertisements in %ds on :%d\n",
|
||||
count.load(), seconds, port);
|
||||
return count.load() > 0 ? 0 : 2;
|
||||
}
|
||||
Reference in new issue
Block a user