Skip to content

Commit 82f33b9

Browse files
authored
fix(iotwireless): persist thing/certificate/partner-account associations (bug-hunt) (#2283)
2 parents d1a2a97 + 4bc5e24 commit 82f33b9

2 files changed

Lines changed: 338 additions & 0 deletions

File tree

crates/fakecloud-e2e/tests/iotwireless.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,3 +285,106 @@ async fn iotwireless_stub_fixes_round_trip() {
285285
.expect("send_data_to_wireless_device");
286286
assert!(sent.message_id().is_some_and(|m| !m.is_empty()));
287287
}
288+
289+
#[tokio::test]
290+
async fn iotwireless_gateway_thing_and_certificate_associations() {
291+
// Bug-hunt 1.22: Associate*WithThing / *WithCertificate were accept-and-
292+
// discard no-ops. Associations must round-trip through the reads.
293+
let server = TestServer::start().await;
294+
let client = iotwireless_client(&server).await;
295+
296+
let gw = client
297+
.create_wireless_gateway()
298+
.lo_ra_wan(
299+
aws_sdk_iotwireless::types::LoRaWanGateway::builder()
300+
.gateway_eui("0000000000000001")
301+
.rf_region("US915")
302+
.build(),
303+
)
304+
.send()
305+
.await
306+
.expect("create_wireless_gateway");
307+
let gw_id = gw.id().expect("gateway id").to_string();
308+
309+
// Associate with an IoT thing.
310+
let thing_arn = "arn:aws:iot:us-east-1:000000000000:thing/gw-thing";
311+
client
312+
.associate_wireless_gateway_with_thing()
313+
.id(&gw_id)
314+
.thing_arn(thing_arn)
315+
.send()
316+
.await
317+
.expect("associate_wireless_gateway_with_thing");
318+
let got = client
319+
.get_wireless_gateway()
320+
.identifier(&gw_id)
321+
.identifier_type(aws_sdk_iotwireless::types::WirelessGatewayIdType::WirelessGatewayId)
322+
.send()
323+
.await
324+
.expect("get_wireless_gateway");
325+
assert_eq!(got.thing_arn(), Some(thing_arn));
326+
assert_eq!(got.thing_name(), Some("gw-thing"));
327+
328+
// Associate a certificate.
329+
client
330+
.associate_wireless_gateway_with_certificate()
331+
.id(&gw_id)
332+
.iot_certificate_id("cert-abc123")
333+
.send()
334+
.await
335+
.expect("associate_wireless_gateway_with_certificate");
336+
let cert = client
337+
.get_wireless_gateway_certificate()
338+
.id(&gw_id)
339+
.send()
340+
.await
341+
.expect("get_wireless_gateway_certificate");
342+
assert_eq!(cert.iot_certificate_id(), Some("cert-abc123"));
343+
344+
// Disassociate the thing clears it.
345+
client
346+
.disassociate_wireless_gateway_from_thing()
347+
.id(&gw_id)
348+
.send()
349+
.await
350+
.expect("disassociate_wireless_gateway_from_thing");
351+
let got = client
352+
.get_wireless_gateway()
353+
.identifier(&gw_id)
354+
.identifier_type(aws_sdk_iotwireless::types::WirelessGatewayIdType::WirelessGatewayId)
355+
.send()
356+
.await
357+
.expect("get after disassociate");
358+
assert!(got.thing_arn().is_none() || got.thing_arn() == Some(""));
359+
}
360+
361+
#[tokio::test]
362+
async fn iotwireless_partner_account_association() {
363+
// Bug-hunt 1.22: AssociateAwsAccountWithPartnerAccount persisted nothing.
364+
let server = TestServer::start().await;
365+
let client = iotwireless_client(&server).await;
366+
367+
client
368+
.associate_aws_account_with_partner_account()
369+
.sidewalk(
370+
aws_sdk_iotwireless::types::SidewalkAccountInfo::builder()
371+
.amazon_id("amzn-partner-1")
372+
.app_server_private_key("0123456789abcdef")
373+
.build(),
374+
)
375+
.send()
376+
.await
377+
.expect("associate_aws_account_with_partner_account");
378+
379+
let list = client
380+
.list_partner_accounts()
381+
.send()
382+
.await
383+
.expect("list_partner_accounts");
384+
assert!(
385+
list.sidewalk()
386+
.iter()
387+
.any(|s| s.amazon_id() == Some("amzn-partner-1")),
388+
"partner account must be listed"
389+
);
390+
}

crates/fakecloud-iotwireless/src/service/special.rs

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,63 @@ pub(super) fn dispatch(
149149
&fuota_multicast_key(labels),
150150
labels.get("MulticastGroupId").map(String::as_str),
151151
))),
152+
// Wireless device/gateway <-> IoT thing. Sets ThingArn/ThingName on
153+
// the stored record so GetWirelessDevice/GetWirelessGateway reflect it.
154+
"AssociateWirelessDeviceWithThing" => Ok(Some(associate_thing(
155+
svc,
156+
ctx,
157+
"wireless-devices",
158+
labels.get("Id").map(String::as_str),
159+
body,
160+
))),
161+
"DisassociateWirelessDeviceFromThing" => Ok(Some(disassociate_thing(
162+
svc,
163+
ctx,
164+
"wireless-devices",
165+
labels.get("Id").map(String::as_str),
166+
))),
167+
"AssociateWirelessGatewayWithThing" => Ok(Some(associate_thing(
168+
svc,
169+
ctx,
170+
"wireless-gateways",
171+
labels.get("Id").map(String::as_str),
172+
body,
173+
))),
174+
"DisassociateWirelessGatewayFromThing" => Ok(Some(disassociate_thing(
175+
svc,
176+
ctx,
177+
"wireless-gateways",
178+
labels.get("Id").map(String::as_str),
179+
))),
180+
181+
// Wireless gateway <-> IoT certificate.
182+
"AssociateWirelessGatewayWithCertificate" => Ok(Some(associate_gateway_certificate(
183+
svc,
184+
ctx,
185+
labels.get("Id").map(String::as_str),
186+
body,
187+
))),
188+
"DisassociateWirelessGatewayFromCertificate" => Ok(Some(disassociate_thing_field(
189+
svc,
190+
ctx,
191+
"wireless-gateways",
192+
labels.get("Id").map(String::as_str),
193+
&["IotCertificateId"],
194+
))),
195+
"GetWirelessGatewayCertificate" => {
196+
Ok(Some(get_wireless_gateway_certificate(svc, ctx, labels)))
197+
}
198+
199+
// AWS-account <-> Sidewalk partner account.
200+
"AssociateAwsAccountWithPartnerAccount" => {
201+
Ok(Some(associate_partner_account(svc, ctx, body)))
202+
}
203+
"DisassociateAwsAccountFromPartnerAccount" => Ok(Some(disassociate_partner_account(
204+
svc,
205+
ctx,
206+
labels.get("PartnerAccountId").map(String::as_str),
207+
))),
208+
152209
"ListMulticastGroupsByFuotaTask" => {
153210
Ok(Some(list_multicast_groups_by_fuota_task(svc, ctx, labels)))
154211
}
@@ -704,6 +761,184 @@ fn disassociate(
704761
(ok_json(Value::Object(Map::new())), true)
705762
}
706763

764+
/// Set `ThingArn` (+ derived `ThingName`) on a stored wireless device/gateway
765+
/// record so the matching Get reflects the association.
766+
fn associate_thing(
767+
svc: &IotWirelessService,
768+
ctx: &Ctx,
769+
rtype: &str,
770+
id: Option<&str>,
771+
body: &Map<String, Value>,
772+
) -> (AwsResponse, bool) {
773+
let (Some(id), Some(thing_arn)) = (id, body.get("ThingArn").and_then(Value::as_str)) else {
774+
return (ok_json(Value::Object(Map::new())), false);
775+
};
776+
let thing_name = thing_arn
777+
.rsplit_once("thing/")
778+
.map(|(_, n)| n)
779+
.unwrap_or("");
780+
let mut g = svc.state.write();
781+
let data = g.get_or_create(&ctx.account);
782+
if let Some(record) = data.get_resource(rtype, id).cloned() {
783+
let mut record = record;
784+
if let Some(obj) = record.as_object_mut() {
785+
obj.insert("ThingArn".to_string(), json!(thing_arn));
786+
obj.insert("ThingName".to_string(), json!(thing_name));
787+
}
788+
data.put_resource(rtype, id, record);
789+
}
790+
(ok_json(Value::Object(Map::new())), true)
791+
}
792+
793+
/// Clear `ThingArn`/`ThingName` on a wireless device/gateway record.
794+
fn disassociate_thing(
795+
svc: &IotWirelessService,
796+
ctx: &Ctx,
797+
rtype: &str,
798+
id: Option<&str>,
799+
) -> (AwsResponse, bool) {
800+
disassociate_thing_field(svc, ctx, rtype, id, &["ThingArn", "ThingName"])
801+
}
802+
803+
/// Remove the named fields from a stored record (used by the disassociate ops).
804+
fn disassociate_thing_field(
805+
svc: &IotWirelessService,
806+
ctx: &Ctx,
807+
rtype: &str,
808+
id: Option<&str>,
809+
fields: &[&str],
810+
) -> (AwsResponse, bool) {
811+
if let Some(id) = id {
812+
let mut g = svc.state.write();
813+
let data = g.get_or_create(&ctx.account);
814+
if let Some(mut record) = data.get_resource(rtype, id).cloned() {
815+
if let Some(obj) = record.as_object_mut() {
816+
for f in fields {
817+
obj.remove(*f);
818+
}
819+
}
820+
data.put_resource(rtype, id, record);
821+
}
822+
}
823+
(ok_json(Value::Object(Map::new())), true)
824+
}
825+
826+
/// Store `IotCertificateId` on the wireless-gateway record.
827+
fn associate_gateway_certificate(
828+
svc: &IotWirelessService,
829+
ctx: &Ctx,
830+
id: Option<&str>,
831+
body: &Map<String, Value>,
832+
) -> (AwsResponse, bool) {
833+
let (Some(id), Some(cert)) = (id, body.get("IotCertificateId").and_then(Value::as_str)) else {
834+
return (ok_json(Value::Object(Map::new())), false);
835+
};
836+
let mut g = svc.state.write();
837+
let data = g.get_or_create(&ctx.account);
838+
if let Some(mut record) = data.get_resource("wireless-gateways", id).cloned() {
839+
if let Some(obj) = record.as_object_mut() {
840+
obj.insert("IotCertificateId".to_string(), json!(cert));
841+
}
842+
data.put_resource("wireless-gateways", id, record);
843+
}
844+
(ok_json(json!({ "IotCertificateId": cert })), true)
845+
}
846+
847+
/// GetWirelessGatewayCertificate (a Verb::Action, so not served by the generic
848+
/// engine): project the cert id stored on the gateway record.
849+
fn get_wireless_gateway_certificate(
850+
svc: &IotWirelessService,
851+
ctx: &Ctx,
852+
labels: &HashMap<String, String>,
853+
) -> (AwsResponse, bool) {
854+
let id = labels.get("Id").map(String::as_str).unwrap_or("");
855+
let g = svc.state.read();
856+
let cert = g
857+
.get(&ctx.account)
858+
.and_then(|d| d.get_resource("wireless-gateways", id))
859+
.and_then(|r| r.get("IotCertificateId"))
860+
.and_then(Value::as_str)
861+
.unwrap_or("")
862+
.to_string();
863+
(ok_json(json!({ "IotCertificateId": cert })), false)
864+
}
865+
866+
/// A deterministic 64-character hex digest of an input, standing in for the
867+
/// SHA-256 fingerprint AWS reports for a Sidewalk app-server private key. Built
868+
/// from eight salted FNV-1a folds so the same key always maps to the same
869+
/// digest without pulling in a crypto dependency.
870+
fn fingerprint_hex(input: &str) -> String {
871+
let mut out = String::with_capacity(64);
872+
for salt in 0u8..8 {
873+
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
874+
hash ^= salt as u64;
875+
for b in input.bytes() {
876+
hash ^= b as u64;
877+
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
878+
}
879+
out.push_str(&format!("{hash:016x}"));
880+
}
881+
out
882+
}
883+
884+
/// Persist a Sidewalk partner-account association keyed by its AmazonId so
885+
/// GetPartnerAccount / ListPartnerAccounts (generic reads) reflect it.
886+
fn associate_partner_account(
887+
svc: &IotWirelessService,
888+
ctx: &Ctx,
889+
body: &Map<String, Value>,
890+
) -> (AwsResponse, bool) {
891+
let sidewalk = body.get("Sidewalk").cloned().unwrap_or(json!({}));
892+
let amazon_id = sidewalk
893+
.get("AmazonId")
894+
.and_then(Value::as_str)
895+
.unwrap_or("")
896+
.to_string();
897+
// AWS returns a fingerprint of the app-server private key, never the key
898+
// itself. Derive a deterministic 64-hex digest so Get/List round-trip.
899+
let private_key = sidewalk
900+
.get("AppServerPrivateKey")
901+
.and_then(Value::as_str)
902+
.unwrap_or("");
903+
let fingerprint = fingerprint_hex(private_key);
904+
// The SidewalkAccountInfoWithFingerprint the reads project carries the
905+
// AmazonId + Fingerprint (never the private key).
906+
let sidewalk_with_fp = json!({
907+
"AmazonId": amazon_id,
908+
"Fingerprint": fingerprint,
909+
});
910+
// Store the projected list-element members (AmazonId/Fingerprint/Arn) at the
911+
// top level so the generic ListPartnerAccounts projection finds them, and
912+
// keep the nested Sidewalk object for GetPartnerAccount.
913+
let record = json!({
914+
"AmazonId": amazon_id,
915+
"Fingerprint": fingerprint,
916+
"Sidewalk": sidewalk_with_fp,
917+
"PartnerAccountId": amazon_id,
918+
"PartnerType": "Sidewalk",
919+
});
920+
let mut g = svc.state.write();
921+
let data = g.get_or_create(&ctx.account);
922+
data.put_resource("partner-accounts", &amazon_id, record.clone());
923+
(ok_json(record), true)
924+
}
925+
926+
/// Remove a partner-account association.
927+
fn disassociate_partner_account(
928+
svc: &IotWirelessService,
929+
ctx: &Ctx,
930+
id: Option<&str>,
931+
) -> (AwsResponse, bool) {
932+
if let Some(id) = id {
933+
let mut g = svc.state.write();
934+
let data = g.get_or_create(&ctx.account);
935+
data.resources
936+
.get_mut("partner-accounts")
937+
.map(|m| m.remove(id));
938+
}
939+
(ok_json(Value::Object(Map::new())), true)
940+
}
941+
707942
/// `ListMulticastGroupsByFuotaTask`: read the FUOTA-task -> multicast-group
708943
/// edges persisted by `AssociateMulticastGroupWithFuotaTask`.
709944
fn list_multicast_groups_by_fuota_task(

0 commit comments

Comments
 (0)