Skip to main content

fakecloud_managedblockchain/
service.rs

1//! Amazon Managed Blockchain (`managedblockchain`) restJson1 dispatch + handlers.
2//!
3//! The full 27-operation Managed Blockchain control plane. Requests are routed
4//! to an operation by HTTP method + `@http` URI path under `/`; path labels are
5//! captured positionally (percent-decoded, so an ARN label whose slashes/colons
6//! arrive percent-encoded survives intact) and query parameters are read from
7//! the raw query string. State is account-partitioned and persisted; each
8//! resource is stored as its already-output-valid wire object so `Get*` echoes
9//! exactly what `Create*` persisted.
10
11use std::sync::Arc;
12
13use async_trait::async_trait;
14use http::{Method, StatusCode};
15use percent_encoding::percent_decode_str;
16use serde_json::{json, Map, Value};
17use tokio::sync::Mutex as AsyncMutex;
18
19use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
20use fakecloud_persistence::SnapshotStore;
21
22use crate::persistence::save_snapshot;
23use crate::shared;
24use crate::state::{ManagedBlockchainData, SharedManagedBlockchainState};
25
26/// Every operation name in the Amazon Managed Blockchain Smithy model (27).
27pub const MANAGEDBLOCKCHAIN_ACTIONS: &[&str] = &[
28    "CreateAccessor",
29    "CreateMember",
30    "CreateNetwork",
31    "CreateNode",
32    "CreateProposal",
33    "DeleteAccessor",
34    "DeleteMember",
35    "DeleteNode",
36    "GetAccessor",
37    "GetMember",
38    "GetNetwork",
39    "GetNode",
40    "GetProposal",
41    "ListAccessors",
42    "ListInvitations",
43    "ListMembers",
44    "ListNetworks",
45    "ListNodes",
46    "ListProposalVotes",
47    "ListProposals",
48    "ListTagsForResource",
49    "RejectInvitation",
50    "TagResource",
51    "UntagResource",
52    "UpdateMember",
53    "UpdateNode",
54    "VoteOnProposal",
55];
56
57/// Operations that mutate persisted state on success (so a snapshot is taken).
58const MUTATING: &[&str] = &[
59    "CreateAccessor",
60    "CreateMember",
61    "CreateNetwork",
62    "CreateNode",
63    "CreateProposal",
64    "DeleteAccessor",
65    "DeleteMember",
66    "DeleteNode",
67    "RejectInvitation",
68    "TagResource",
69    "UntagResource",
70    "UpdateMember",
71    "UpdateNode",
72    "VoteOnProposal",
73];
74
75pub struct ManagedBlockchainService {
76    state: SharedManagedBlockchainState,
77    snapshot_store: Option<Arc<dyn SnapshotStore>>,
78    snapshot_lock: Arc<AsyncMutex<()>>,
79}
80
81impl ManagedBlockchainService {
82    pub fn new(state: SharedManagedBlockchainState) -> Self {
83        Self {
84            state,
85            snapshot_store: None,
86            snapshot_lock: Arc::new(AsyncMutex::new(())),
87        }
88    }
89
90    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
91        self.snapshot_store = Some(store);
92        self
93    }
94
95    async fn save(&self) {
96        save_snapshot(
97            &self.state,
98            self.snapshot_store.clone(),
99            &self.snapshot_lock,
100        )
101        .await;
102    }
103
104    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
105    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
106        let store = self.snapshot_store.clone()?;
107        let state = self.state.clone();
108        let lock = self.snapshot_lock.clone();
109        Some(Arc::new(move || {
110            let state = state.clone();
111            let store = store.clone();
112            let lock = lock.clone();
113            Box::pin(async move {
114                save_snapshot(&state, Some(store), &lock).await;
115            })
116        }))
117    }
118
119    /// Settle any in-flight resource lifecycle transition for the account:
120    /// members and nodes created `CREATING` settle to `AVAILABLE`, and any
121    /// `IN_PROGRESS` proposal whose `ExpirationDate` has passed settles to
122    /// `EXPIRED`. Returns `true` if a transition fired (so the caller persists).
123    fn reconcile(&self, account: &str) -> bool {
124        let now = shared::iso_now();
125        let mut guard = self.state.write();
126        let data = guard.get_or_create(account);
127        let mut changed = false;
128        for m in data.members.values_mut() {
129            if promote_creating(m) {
130                changed = true;
131            }
132        }
133        for n in data.nodes.values_mut() {
134            if promote_creating(n) {
135                changed = true;
136            }
137        }
138        for p in data.proposals.values_mut() {
139            if let Some(obj) = p.as_object_mut() {
140                let status = obj.get("Status").and_then(Value::as_str).unwrap_or("");
141                let expired = obj
142                    .get("ExpirationDate")
143                    .and_then(Value::as_str)
144                    .map(|e| e < now.as_str())
145                    .unwrap_or(false);
146                if status == "IN_PROGRESS" && expired {
147                    obj.insert("Status".into(), json!("EXPIRED"));
148                    changed = true;
149                }
150            }
151        }
152        changed
153    }
154
155    /// Route a request to an operation name + captured path labels by HTTP
156    /// method + `@http` URI path. Returns `None` when no route matches.
157    fn resolve_action(req: &AwsRequest) -> Option<(&'static str, Vec<String>)> {
158        let raw = req.raw_path.split('?').next().unwrap_or(&req.raw_path);
159        let trimmed = raw.strip_prefix('/').unwrap_or(raw);
160        let segs: Vec<String> = if trimmed.is_empty() {
161            Vec::new()
162        } else {
163            trimmed
164                .split('/')
165                .map(|s| percent_decode_str(s).decode_utf8_lossy().into_owned())
166                .collect()
167        };
168        let s: Vec<&str> = segs.iter().map(String::as_str).collect();
169        let m = &req.method;
170        let get = m == Method::GET;
171        let post = m == Method::POST;
172        let del = m == Method::DELETE;
173        let patch = m == Method::PATCH;
174        let l = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
175        let (action, labels): (&'static str, Vec<String>) = match s.as_slice() {
176            ["networks"] if post => ("CreateNetwork", vec![]),
177            ["networks"] if get => ("ListNetworks", vec![]),
178            ["networks", nid] if get => ("GetNetwork", l(&[nid])),
179            ["networks", nid, "members"] if post => ("CreateMember", l(&[nid])),
180            ["networks", nid, "members"] if get => ("ListMembers", l(&[nid])),
181            ["networks", nid, "members", mid] if get => ("GetMember", l(&[nid, mid])),
182            ["networks", nid, "members", mid] if patch => ("UpdateMember", l(&[nid, mid])),
183            ["networks", nid, "members", mid] if del => ("DeleteMember", l(&[nid, mid])),
184            ["networks", nid, "nodes"] if post => ("CreateNode", l(&[nid])),
185            ["networks", nid, "nodes"] if get => ("ListNodes", l(&[nid])),
186            ["networks", nid, "nodes", ndid] if get => ("GetNode", l(&[nid, ndid])),
187            ["networks", nid, "nodes", ndid] if patch => ("UpdateNode", l(&[nid, ndid])),
188            ["networks", nid, "nodes", ndid] if del => ("DeleteNode", l(&[nid, ndid])),
189            ["networks", nid, "proposals"] if post => ("CreateProposal", l(&[nid])),
190            ["networks", nid, "proposals"] if get => ("ListProposals", l(&[nid])),
191            ["networks", nid, "proposals", pid] if get => ("GetProposal", l(&[nid, pid])),
192            ["networks", nid, "proposals", pid, "votes"] if get => {
193                ("ListProposalVotes", l(&[nid, pid]))
194            }
195            ["networks", nid, "proposals", pid, "votes"] if post => {
196                ("VoteOnProposal", l(&[nid, pid]))
197            }
198            ["accessors"] if post => ("CreateAccessor", vec![]),
199            ["accessors"] if get => ("ListAccessors", vec![]),
200            ["accessors", aid] if get => ("GetAccessor", l(&[aid])),
201            ["accessors", aid] if del => ("DeleteAccessor", l(&[aid])),
202            ["invitations"] if get => ("ListInvitations", vec![]),
203            ["invitations", iid] if del => ("RejectInvitation", l(&[iid])),
204            ["tags", arn] if post => ("TagResource", l(&[arn])),
205            ["tags", arn] if get => ("ListTagsForResource", l(&[arn])),
206            ["tags", arn] if del => ("UntagResource", l(&[arn])),
207            _ => return None,
208        };
209        Some((action, labels))
210    }
211}
212
213#[async_trait]
214impl AwsService for ManagedBlockchainService {
215    fn service_name(&self) -> &str {
216        "managedblockchain"
217    }
218
219    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
220        let Some((action, labels)) = Self::resolve_action(&req) else {
221            return Err(AwsServiceError::aws_error(
222                StatusCode::NOT_FOUND,
223                "ResourceNotFoundException",
224                format!("Unknown operation: {} {}", req.method, req.raw_path),
225            ));
226        };
227        let (result, settled) = self.dispatch(action, &labels, &req);
228        let success = matches!(result.as_ref(), Ok(resp) if resp.status.is_success());
229        if settled || (MUTATING.contains(&action) && success) {
230            self.save().await;
231        }
232        result
233    }
234
235    fn supported_actions(&self) -> &[&str] {
236        MANAGEDBLOCKCHAIN_ACTIONS
237    }
238}
239
240/// Per-request account + region context.
241struct Ctx {
242    account: String,
243    region: String,
244}
245
246impl ManagedBlockchainService {
247    fn dispatch(
248        &self,
249        action: &str,
250        labels: &[String],
251        req: &AwsRequest,
252    ) -> (Result<AwsResponse, AwsServiceError>, bool) {
253        let body = match parse_body(req) {
254            Ok(b) => b,
255            Err(e) => return (Err(e), false),
256        };
257        if let Err(e) = crate::validate::validate_input(action, &body) {
258            return (Err(e), false);
259        }
260        let ctx = Ctx {
261            account: req.account_id.clone(),
262            region: req.region.clone(),
263        };
264        let q = parse_query(&req.raw_query);
265        let settled = self.reconcile(&ctx.account);
266        let a = |i: usize| labels.get(i).map(String::as_str).unwrap_or_default();
267        let result = match action {
268            "CreateNetwork" => self.create_network(&ctx, &body),
269            "GetNetwork" => self.get_network(&ctx, a(0)),
270            "ListNetworks" => self.list_networks(&ctx, &q),
271            "CreateMember" => self.create_member(&ctx, a(0), &body),
272            "GetMember" => self.get_member(&ctx, a(0), a(1)),
273            "UpdateMember" => self.update_member(&ctx, a(0), a(1), &body),
274            "DeleteMember" => self.delete_member(&ctx, a(0), a(1)),
275            "ListMembers" => self.list_members(&ctx, a(0), &q),
276            "CreateNode" => self.create_node(&ctx, a(0), &body),
277            "GetNode" => self.get_node(&ctx, a(0), a(1)),
278            "UpdateNode" => self.update_node(&ctx, a(0), a(1), &body),
279            "DeleteNode" => self.delete_node(&ctx, a(0), a(1)),
280            "ListNodes" => self.list_nodes(&ctx, a(0), &q),
281            "CreateProposal" => self.create_proposal(&ctx, a(0), &body),
282            "GetProposal" => self.get_proposal(&ctx, a(0), a(1)),
283            "ListProposals" => self.list_proposals(&ctx, a(0), &q),
284            "ListProposalVotes" => self.list_proposal_votes(&ctx, a(0), a(1), &q),
285            "VoteOnProposal" => self.vote_on_proposal(&ctx, a(0), a(1), &body),
286            "CreateAccessor" => self.create_accessor(&ctx, &body),
287            "GetAccessor" => self.get_accessor(&ctx, a(0)),
288            "DeleteAccessor" => self.delete_accessor(&ctx, a(0)),
289            "ListAccessors" => self.list_accessors(&ctx, &q),
290            "ListInvitations" => self.list_invitations(&ctx, &q),
291            "RejectInvitation" => self.reject_invitation(&ctx, a(0)),
292            "TagResource" => self.tag_resource(&ctx, a(0), &body),
293            "UntagResource" => self.untag_resource(&ctx, a(0), &q),
294            "ListTagsForResource" => self.list_tags(&ctx, a(0)),
295            _ => Err(AwsServiceError::action_not_implemented(
296                "managedblockchain",
297                action,
298            )),
299        };
300        (result, settled)
301    }
302
303    // ------------------------------ Networks ----------------------------
304
305    fn create_network(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
306        let now = shared::iso_now();
307        let network_id = shared::new_network_id();
308        let framework = str_or(body, "Framework", "HYPERLEDGER_FABRIC");
309        let member_id = shared::new_member_id();
310
311        let mut net = Map::new();
312        net.insert("Id".into(), json!(network_id));
313        net.insert("Name".into(), json!(str_or(body, "Name", "")));
314        net.insert("Framework".into(), json!(framework));
315        net.insert(
316            "FrameworkVersion".into(),
317            json!(str_or(body, "FrameworkVersion", "")),
318        );
319        net.insert("Status".into(), json!("AVAILABLE"));
320        net.insert("CreationDate".into(), json!(now));
321        net.insert("Arn".into(), json!(shared::network_arn(&network_id)));
322        net.insert(
323            "VpcEndpointServiceName".into(),
324            json!(format!(
325                "com.amazonaws.{}.managedblockchain.{}",
326                ctx.region,
327                network_id.to_lowercase()
328            )),
329        );
330        echo(&mut net, body, &["Description", "VotingPolicy"]);
331        net.insert(
332            "FrameworkAttributes".into(),
333            network_framework_attributes(&framework, &network_id, body, &ctx.region),
334        );
335
336        let is_fabric = framework == "HYPERLEDGER_FABRIC";
337
338        let mut guard = self.state.write();
339        let data = guard.get_or_create(&ctx.account);
340        store_tags(data, &shared::network_arn(&network_id), body);
341        data.networks.insert(network_id.clone(), Value::Object(net));
342
343        // Hyperledger Fabric networks atomically create the requested first
344        // member; Ethereum networks do not.
345        let mut out = json!({ "NetworkId": network_id });
346        if is_fabric {
347            let member_config = body
348                .get("MemberConfiguration")
349                .cloned()
350                .unwrap_or(json!({}));
351            let member = build_member(ctx, &network_id, &member_id, &member_config, &now, true);
352            let arn = shared::member_arn(&ctx.region, &ctx.account, &member_id);
353            store_tags(data, &arn, &member_config);
354            data.members.insert(member_id.clone(), member);
355            out.as_object_mut()
356                .unwrap()
357                .insert("MemberId".into(), json!(member_id));
358        }
359        ok(out)
360    }
361
362    fn get_network(&self, ctx: &Ctx, network_id: &str) -> Result<AwsResponse, AwsServiceError> {
363        let guard = self.state.read();
364        let data = guard.get(&ctx.account);
365        match data.and_then(|d| d.networks.get(network_id)) {
366            Some(n) => {
367                let arn = shared::network_arn(network_id);
368                ok(json!({ "Network": with_tags(data.unwrap(), &arn, n) }))
369            }
370            None => Err(not_found(&format!("Network {network_id} was not found."))),
371        }
372    }
373
374    fn list_networks(
375        &self,
376        ctx: &Ctx,
377        q: &[(String, String)],
378    ) -> Result<AwsResponse, AwsServiceError> {
379        validate_list_query(
380            q,
381            10,
382            &[
383                ("framework", crate::validate::FRAMEWORK),
384                ("status", NETWORK_STATUS),
385            ],
386        )?;
387        let name = query_one(q, "name");
388        let framework = query_one(q, "framework");
389        let status = query_one(q, "status");
390        let guard = self.state.read();
391        let mut networks: Vec<Value> = guard
392            .get(&ctx.account)
393            .map(|d| {
394                d.networks
395                    .values()
396                    .filter(|n| {
397                        opt_eq(n, "Name", name)
398                            && opt_eq(n, "Framework", framework)
399                            && opt_eq(n, "Status", status)
400                    })
401                    .map(network_summary)
402                    .collect()
403            })
404            .unwrap_or_default();
405        networks.sort_by(|a, b| summary_id(a).cmp(summary_id(b)));
406        let (page, next) = paginate(networks, q)?;
407        let mut out = json!({ "Networks": page });
408        if let Some(n) = next {
409            out["NextToken"] = json!(n);
410        }
411        ok(out)
412    }
413
414    // ------------------------------ Members -----------------------------
415
416    fn create_member(
417        &self,
418        ctx: &Ctx,
419        network_id: &str,
420        body: &Value,
421    ) -> Result<AwsResponse, AwsServiceError> {
422        check_id(network_id, "NetworkId")?;
423        let invitation_id = str_or(body, "InvitationId", "");
424        let now = shared::iso_now();
425        let member_id = shared::new_member_id();
426        let member_config = body
427            .get("MemberConfiguration")
428            .cloned()
429            .unwrap_or(json!({}));
430
431        let mut guard = self.state.write();
432        let data = guard.get_or_create(&ctx.account);
433        if !data.networks.contains_key(network_id) {
434            return Err(not_found(&format!("Network {network_id} was not found.")));
435        }
436        // The invitation must exist AND still be PENDING; joining consumes it by
437        // flipping it to ACCEPTED so the same invitation cannot mint a second
438        // member. A REJECTED / EXPIRED / already-ACCEPTED invitation is rejected.
439        match data
440            .invitations
441            .get(&invitation_id)
442            .and_then(|inv| inv.get("Status"))
443            .and_then(Value::as_str)
444        {
445            Some("PENDING") => {
446                if let Some(obj) = data
447                    .invitations
448                    .get_mut(&invitation_id)
449                    .and_then(Value::as_object_mut)
450                {
451                    obj.insert("Status".into(), json!("ACCEPTED"));
452                }
453            }
454            _ => {
455                return Err(invalid_request(&format!(
456                    "Invitation {invitation_id} was not found or is not pending."
457                )));
458            }
459        }
460        let member = build_member(ctx, network_id, &member_id, &member_config, &now, true);
461        let arn = shared::member_arn(&ctx.region, &ctx.account, &member_id);
462        store_tags(data, &arn, &member_config);
463        data.members.insert(member_id.clone(), member);
464        ok(json!({ "MemberId": member_id }))
465    }
466
467    fn get_member(
468        &self,
469        ctx: &Ctx,
470        _network_id: &str,
471        member_id: &str,
472    ) -> Result<AwsResponse, AwsServiceError> {
473        let guard = self.state.read();
474        let data = guard.get(&ctx.account);
475        match data.and_then(|d| d.members.get(member_id)) {
476            Some(m) => {
477                let arn = shared::member_arn(&ctx.region, &ctx.account, member_id);
478                ok(json!({ "Member": with_tags(data.unwrap(), &arn, m) }))
479            }
480            None => Err(not_found(&format!("Member {member_id} was not found."))),
481        }
482    }
483
484    fn update_member(
485        &self,
486        ctx: &Ctx,
487        _network_id: &str,
488        member_id: &str,
489        body: &Value,
490    ) -> Result<AwsResponse, AwsServiceError> {
491        let mut guard = self.state.write();
492        let data = guard.get_or_create(&ctx.account);
493        let Some(m) = data
494            .members
495            .get_mut(member_id)
496            .and_then(Value::as_object_mut)
497        else {
498            return Err(not_found(&format!("Member {member_id} was not found.")));
499        };
500        if let Some(lpc) = body.get("LogPublishingConfiguration") {
501            m.insert("LogPublishingConfiguration".into(), lpc.clone());
502        }
503        ok_empty()
504    }
505
506    fn delete_member(
507        &self,
508        ctx: &Ctx,
509        _network_id: &str,
510        member_id: &str,
511    ) -> Result<AwsResponse, AwsServiceError> {
512        let mut guard = self.state.write();
513        let data = guard.get_or_create(&ctx.account);
514        let Some(m) = data
515            .members
516            .get_mut(member_id)
517            .and_then(Value::as_object_mut)
518        else {
519            return Err(not_found(&format!("Member {member_id} was not found.")));
520        };
521        m.insert("Status".into(), json!("DELETED"));
522        ok_empty()
523    }
524
525    fn list_members(
526        &self,
527        ctx: &Ctx,
528        network_id: &str,
529        q: &[(String, String)],
530    ) -> Result<AwsResponse, AwsServiceError> {
531        check_id(network_id, "NetworkId")?;
532        validate_list_query(q, 20, &[("status", MEMBER_STATUS)])?;
533        let name = query_one(q, "name");
534        let status = query_one(q, "status");
535        let guard = self.state.read();
536        let mut members: Vec<Value> = guard
537            .get(&ctx.account)
538            .map(|d| {
539                d.members
540                    .values()
541                    .filter(|m| {
542                        opt_eq(m, "NetworkId", Some(network_id))
543                            && opt_eq(m, "Name", name)
544                            && opt_eq(m, "Status", status)
545                    })
546                    .map(|m| member_summary(m, &ctx.account, &ctx.region))
547                    .collect()
548            })
549            .unwrap_or_default();
550        members.sort_by(|a, b| summary_id(a).cmp(summary_id(b)));
551        let (page, next) = paginate(members, q)?;
552        let mut out = json!({ "Members": page });
553        if let Some(n) = next {
554            out["NextToken"] = json!(n);
555        }
556        ok(out)
557    }
558
559    // ------------------------------- Nodes ------------------------------
560
561    fn create_node(
562        &self,
563        ctx: &Ctx,
564        network_id: &str,
565        body: &Value,
566    ) -> Result<AwsResponse, AwsServiceError> {
567        check_id(network_id, "NetworkId")?;
568        let now = shared::iso_now();
569        let node_id = shared::new_node_id();
570        let member_id = body
571            .get("MemberId")
572            .and_then(Value::as_str)
573            .map(str::to_string);
574        let node_config = body.get("NodeConfiguration").cloned().unwrap_or(json!({}));
575
576        let mut guard = self.state.write();
577        let data = guard.get_or_create(&ctx.account);
578        if !data.networks.contains_key(network_id) {
579            return Err(not_found(&format!("Network {network_id} was not found.")));
580        }
581        let framework = data
582            .networks
583            .get(network_id)
584            .and_then(|n| n.get("Framework"))
585            .and_then(Value::as_str)
586            .unwrap_or("HYPERLEDGER_FABRIC")
587            .to_string();
588        let node = build_node(
589            ctx,
590            network_id,
591            member_id.as_deref(),
592            &node_id,
593            &node_config,
594            &framework,
595            &now,
596        );
597        let arn = shared::node_arn(&ctx.region, &ctx.account, &node_id);
598        store_tags(data, &arn, body);
599        data.nodes.insert(node_id.clone(), node);
600        ok(json!({ "NodeId": node_id }))
601    }
602
603    fn get_node(
604        &self,
605        ctx: &Ctx,
606        _network_id: &str,
607        node_id: &str,
608    ) -> Result<AwsResponse, AwsServiceError> {
609        let guard = self.state.read();
610        let data = guard.get(&ctx.account);
611        match data.and_then(|d| d.nodes.get(node_id)) {
612            Some(n) => {
613                let arn = shared::node_arn(&ctx.region, &ctx.account, node_id);
614                ok(json!({ "Node": with_tags(data.unwrap(), &arn, n) }))
615            }
616            None => Err(not_found(&format!("Node {node_id} was not found."))),
617        }
618    }
619
620    fn update_node(
621        &self,
622        ctx: &Ctx,
623        _network_id: &str,
624        node_id: &str,
625        body: &Value,
626    ) -> Result<AwsResponse, AwsServiceError> {
627        let mut guard = self.state.write();
628        let data = guard.get_or_create(&ctx.account);
629        let Some(n) = data.nodes.get_mut(node_id).and_then(Value::as_object_mut) else {
630            return Err(not_found(&format!("Node {node_id} was not found.")));
631        };
632        if let Some(lpc) = body.get("LogPublishingConfiguration") {
633            n.insert("LogPublishingConfiguration".into(), lpc.clone());
634        }
635        ok_empty()
636    }
637
638    fn delete_node(
639        &self,
640        ctx: &Ctx,
641        _network_id: &str,
642        node_id: &str,
643    ) -> Result<AwsResponse, AwsServiceError> {
644        let mut guard = self.state.write();
645        let data = guard.get_or_create(&ctx.account);
646        let Some(n) = data.nodes.get_mut(node_id).and_then(Value::as_object_mut) else {
647            return Err(not_found(&format!("Node {node_id} was not found.")));
648        };
649        n.insert("Status".into(), json!("DELETED"));
650        ok_empty()
651    }
652
653    fn list_nodes(
654        &self,
655        ctx: &Ctx,
656        network_id: &str,
657        q: &[(String, String)],
658    ) -> Result<AwsResponse, AwsServiceError> {
659        check_id(network_id, "NetworkId")?;
660        validate_list_query(q, 20, &[("status", NODE_STATUS)])?;
661        // `memberId` is a `ResourceIdString` (1..=32); validate it even when
662        // present-but-empty (a too-short negative variant), which `query_one`
663        // would otherwise drop.
664        if let Some((_, mid)) = q.iter().find(|(k, _)| k == "memberId") {
665            check_id(mid, "MemberId")?;
666        }
667        let member_id = query_one(q, "memberId");
668        let status = query_one(q, "status");
669        let guard = self.state.read();
670        let mut nodes: Vec<Value> = guard
671            .get(&ctx.account)
672            .map(|d| {
673                d.nodes
674                    .values()
675                    .filter(|n| {
676                        opt_eq(n, "NetworkId", Some(network_id))
677                            && opt_eq(n, "MemberId", member_id)
678                            && opt_eq(n, "Status", status)
679                    })
680                    .map(node_summary)
681                    .collect()
682            })
683            .unwrap_or_default();
684        nodes.sort_by(|a, b| summary_id(a).cmp(summary_id(b)));
685        let (page, next) = paginate(nodes, q)?;
686        let mut out = json!({ "Nodes": page });
687        if let Some(n) = next {
688            out["NextToken"] = json!(n);
689        }
690        ok(out)
691    }
692
693    // ----------------------------- Proposals ----------------------------
694
695    fn create_proposal(
696        &self,
697        ctx: &Ctx,
698        network_id: &str,
699        body: &Value,
700    ) -> Result<AwsResponse, AwsServiceError> {
701        let now = shared::iso_now();
702        let proposal_id = shared::new_proposal_id();
703        let member_id = str_or(body, "MemberId", "");
704        let actions = body.get("Actions").cloned().unwrap_or(json!({}));
705
706        let mut guard = self.state.write();
707        let data = guard.get_or_create(&ctx.account);
708        if !data.networks.contains_key(network_id) {
709            return Err(not_found(&format!("Network {network_id} was not found.")));
710        }
711        let proposed_by_name = data
712            .members
713            .get(&member_id)
714            .and_then(|m| m.get("Name"))
715            .and_then(Value::as_str)
716            .unwrap_or("")
717            .to_string();
718        let duration = proposal_duration_hours(data.networks.get(network_id));
719        let outstanding = eligible_member_count(data, network_id);
720        let expiration = (chrono::Utc::now() + chrono::Duration::hours(duration))
721            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
722            .to_string();
723
724        let mut p = Map::new();
725        p.insert("ProposalId".into(), json!(proposal_id));
726        p.insert("NetworkId".into(), json!(network_id));
727        p.insert("Actions".into(), actions);
728        p.insert("ProposedByMemberId".into(), json!(member_id));
729        p.insert("ProposedByMemberName".into(), json!(proposed_by_name));
730        p.insert("Status".into(), json!("IN_PROGRESS"));
731        p.insert("CreationDate".into(), json!(now));
732        p.insert("ExpirationDate".into(), json!(expiration));
733        p.insert("YesVoteCount".into(), json!(0));
734        p.insert("NoVoteCount".into(), json!(0));
735        p.insert("OutstandingVoteCount".into(), json!(outstanding));
736        p.insert(
737            "Arn".into(),
738            json!(shared::proposal_arn(
739                &ctx.region,
740                &ctx.account,
741                &proposal_id
742            )),
743        );
744        if let Some(d) = body.get("Description") {
745            p.insert("Description".into(), d.clone());
746        }
747        store_tags(
748            data,
749            &shared::proposal_arn(&ctx.region, &ctx.account, &proposal_id),
750            body,
751        );
752        data.proposals.insert(proposal_id.clone(), Value::Object(p));
753        data.votes.insert(proposal_id.clone(), Vec::new());
754        ok(json!({ "ProposalId": proposal_id }))
755    }
756
757    fn get_proposal(
758        &self,
759        ctx: &Ctx,
760        _network_id: &str,
761        proposal_id: &str,
762    ) -> Result<AwsResponse, AwsServiceError> {
763        let guard = self.state.read();
764        let data = guard.get(&ctx.account);
765        match data.and_then(|d| d.proposals.get(proposal_id)) {
766            Some(p) => {
767                let arn = shared::proposal_arn(&ctx.region, &ctx.account, proposal_id);
768                ok(json!({ "Proposal": with_tags(data.unwrap(), &arn, p) }))
769            }
770            None => Err(not_found(&format!("Proposal {proposal_id} was not found."))),
771        }
772    }
773
774    fn list_proposals(
775        &self,
776        ctx: &Ctx,
777        network_id: &str,
778        q: &[(String, String)],
779    ) -> Result<AwsResponse, AwsServiceError> {
780        check_id(network_id, "NetworkId")?;
781        validate_list_query(q, 100, &[])?;
782        let guard = self.state.read();
783        let data = guard.get(&ctx.account);
784        if let Some(d) = data {
785            if !d.networks.contains_key(network_id) {
786                return Err(not_found(&format!("Network {network_id} was not found.")));
787            }
788        }
789        let mut proposals: Vec<Value> = data
790            .map(|d| {
791                d.proposals
792                    .values()
793                    .filter(|p| opt_eq(p, "NetworkId", Some(network_id)))
794                    .map(proposal_summary)
795                    .collect()
796            })
797            .unwrap_or_default();
798        proposals.sort_by(|a, b| {
799            a.get("ProposalId")
800                .and_then(Value::as_str)
801                .unwrap_or("")
802                .cmp(b.get("ProposalId").and_then(Value::as_str).unwrap_or(""))
803        });
804        let (page, next) = paginate(proposals, q)?;
805        let mut out = json!({ "Proposals": page });
806        if let Some(n) = next {
807            out["NextToken"] = json!(n);
808        }
809        ok(out)
810    }
811
812    fn list_proposal_votes(
813        &self,
814        ctx: &Ctx,
815        network_id: &str,
816        proposal_id: &str,
817        q: &[(String, String)],
818    ) -> Result<AwsResponse, AwsServiceError> {
819        check_id(network_id, "NetworkId")?;
820        check_id(proposal_id, "ProposalId")?;
821        validate_list_query(q, 100, &[])?;
822        let guard = self.state.read();
823        let votes: Vec<Value> = guard
824            .get(&ctx.account)
825            .and_then(|d| d.votes.get(proposal_id))
826            .cloned()
827            .unwrap_or_default();
828        let (page, next) = paginate(votes, q)?;
829        let mut out = json!({ "ProposalVotes": page });
830        if let Some(n) = next {
831            out["NextToken"] = json!(n);
832        }
833        ok(out)
834    }
835
836    fn vote_on_proposal(
837        &self,
838        ctx: &Ctx,
839        network_id: &str,
840        proposal_id: &str,
841        body: &Value,
842    ) -> Result<AwsResponse, AwsServiceError> {
843        let voter = str_or(body, "VoterMemberId", "");
844        let vote = str_or(body, "Vote", "");
845
846        let mut guard = self.state.write();
847        // Collect the invitations to materialise across accounts once the
848        // proposal is decided; execute them after the caller's borrow ends.
849        let mut invitations_to_send: Vec<(String, Value)> = Vec::new();
850        {
851            let region = ctx.region.clone();
852            let data = guard.get_or_create(&ctx.account);
853            let network = data.networks.get(network_id).cloned();
854            if network.is_none() {
855                return Err(not_found(&format!("Network {network_id} was not found.")));
856            }
857            if !data.members.contains_key(&voter) {
858                return Err(not_found(&format!("Member {voter} was not found.")));
859            }
860            let member_name = data
861                .members
862                .get(&voter)
863                .and_then(|m| m.get("Name"))
864                .and_then(Value::as_str)
865                .unwrap_or("")
866                .to_string();
867
868            {
869                let proposal = data
870                    .proposals
871                    .get(proposal_id)
872                    .ok_or_else(|| not_found(&format!("Proposal {proposal_id} was not found.")))?;
873                let status = proposal.get("Status").and_then(Value::as_str).unwrap_or("");
874                if status != "IN_PROGRESS" {
875                    return Err(illegal_action(&format!(
876                        "Proposal {proposal_id} is not open for voting (status {status})."
877                    )));
878                }
879            }
880
881            let votes = data.votes.entry(proposal_id.to_string()).or_default();
882            if votes
883                .iter()
884                .any(|v| v.get("MemberId").and_then(Value::as_str) == Some(voter.as_str()))
885            {
886                return Err(illegal_action(&format!(
887                    "Member {voter} has already voted on proposal {proposal_id}."
888                )));
889            }
890            votes.push(json!({
891                "Vote": vote,
892                "MemberId": voter,
893                "MemberName": member_name,
894            }));
895            let yes = votes
896                .iter()
897                .filter(|v| v.get("Vote").and_then(Value::as_str) == Some("YES"))
898                .count() as i64;
899            let no = votes
900                .iter()
901                .filter(|v| v.get("Vote").and_then(Value::as_str) == Some("NO"))
902                .count() as i64;
903
904            let total = eligible_member_count(data, network_id);
905            let (threshold, comparator) = threshold_policy(network.as_ref());
906            let outstanding = (total - yes - no).max(0);
907            let decision = decide_proposal(yes, outstanding, total, threshold, &comparator);
908
909            // Apply the decision to the proposal + gather any materialisations.
910            let actions = data
911                .proposals
912                .get(proposal_id)
913                .and_then(|p| p.get("Actions"))
914                .cloned()
915                .unwrap_or(json!({}));
916            if let Some(p) = data
917                .proposals
918                .get_mut(proposal_id)
919                .and_then(Value::as_object_mut)
920            {
921                p.insert("YesVoteCount".into(), json!(yes));
922                p.insert("NoVoteCount".into(), json!(no));
923                p.insert("OutstandingVoteCount".into(), json!(outstanding));
924                p.insert("Status".into(), json!(decision.clone()));
925            }
926
927            if decision == "APPROVED" {
928                // Removals target members in this account/network.
929                if let Some(removals) = actions.get("Removals").and_then(Value::as_array) {
930                    for r in removals {
931                        if let Some(mid) = r.get("MemberId").and_then(Value::as_str) {
932                            if let Some(m) =
933                                data.members.get_mut(mid).and_then(Value::as_object_mut)
934                            {
935                                m.insert("Status".into(), json!("DELETED"));
936                            }
937                        }
938                    }
939                }
940                // Invitations become new `Invitation` records in the invited
941                // principal's account.
942                if let Some(invites) = actions.get("Invitations").and_then(Value::as_array) {
943                    let net = network.as_ref().unwrap();
944                    for inv in invites {
945                        if let Some(principal) = inv.get("Principal").and_then(Value::as_str) {
946                            let invitation = build_invitation(&region, principal, net);
947                            invitations_to_send.push((principal.to_string(), invitation));
948                        }
949                    }
950                }
951            }
952        }
953
954        for (principal, invitation) in invitations_to_send {
955            let inv_id = invitation
956                .get("InvitationId")
957                .and_then(Value::as_str)
958                .unwrap_or_default()
959                .to_string();
960            let acct = guard.get_or_create(&principal);
961            acct.invitations.insert(inv_id, invitation);
962        }
963
964        ok_empty()
965    }
966
967    // ------------------------------ Accessors ---------------------------
968
969    fn create_accessor(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
970        let now = shared::iso_now();
971        let accessor_id = shared::new_accessor_id();
972        let billing_token = shared::new_billing_token();
973        let network_type = body
974            .get("NetworkType")
975            .and_then(Value::as_str)
976            .map(str::to_string);
977
978        let mut acc = Map::new();
979        acc.insert("Id".into(), json!(accessor_id));
980        acc.insert(
981            "Type".into(),
982            json!(str_or(body, "AccessorType", "BILLING_TOKEN")),
983        );
984        acc.insert("BillingToken".into(), json!(billing_token));
985        acc.insert("Status".into(), json!("AVAILABLE"));
986        acc.insert("CreationDate".into(), json!(now));
987        acc.insert(
988            "Arn".into(),
989            json!(shared::accessor_arn(
990                &ctx.region,
991                &ctx.account,
992                &accessor_id
993            )),
994        );
995        if let Some(nt) = &network_type {
996            acc.insert("NetworkType".into(), json!(nt));
997        }
998
999        let mut guard = self.state.write();
1000        let data = guard.get_or_create(&ctx.account);
1001        store_tags(
1002            data,
1003            &shared::accessor_arn(&ctx.region, &ctx.account, &accessor_id),
1004            body,
1005        );
1006        data.accessors
1007            .insert(accessor_id.clone(), Value::Object(acc));
1008
1009        let mut out = json!({ "AccessorId": accessor_id, "BillingToken": billing_token });
1010        if let Some(nt) = network_type {
1011            out["NetworkType"] = json!(nt);
1012        }
1013        ok(out)
1014    }
1015
1016    fn get_accessor(&self, ctx: &Ctx, accessor_id: &str) -> Result<AwsResponse, AwsServiceError> {
1017        let guard = self.state.read();
1018        let data = guard.get(&ctx.account);
1019        match data.and_then(|d| d.accessors.get(accessor_id)) {
1020            Some(a) => {
1021                let arn = shared::accessor_arn(&ctx.region, &ctx.account, accessor_id);
1022                ok(json!({ "Accessor": with_tags(data.unwrap(), &arn, a) }))
1023            }
1024            None => Err(not_found(&format!("Accessor {accessor_id} was not found."))),
1025        }
1026    }
1027
1028    fn delete_accessor(
1029        &self,
1030        ctx: &Ctx,
1031        accessor_id: &str,
1032    ) -> Result<AwsResponse, AwsServiceError> {
1033        let mut guard = self.state.write();
1034        let data = guard.get_or_create(&ctx.account);
1035        let Some(a) = data
1036            .accessors
1037            .get_mut(accessor_id)
1038            .and_then(Value::as_object_mut)
1039        else {
1040            return Err(not_found(&format!("Accessor {accessor_id} was not found.")));
1041        };
1042        a.insert("Status".into(), json!("PENDING_DELETION"));
1043        ok_empty()
1044    }
1045
1046    fn list_accessors(
1047        &self,
1048        ctx: &Ctx,
1049        q: &[(String, String)],
1050    ) -> Result<AwsResponse, AwsServiceError> {
1051        validate_list_query(
1052            q,
1053            50,
1054            &[("networkType", crate::validate::ACCESSOR_NETWORK_TYPE)],
1055        )?;
1056        let network_type = query_one(q, "networkType");
1057        let guard = self.state.read();
1058        let mut accessors: Vec<Value> = guard
1059            .get(&ctx.account)
1060            .map(|d| {
1061                d.accessors
1062                    .values()
1063                    .filter(|a| opt_eq(a, "NetworkType", network_type))
1064                    .map(accessor_summary)
1065                    .collect()
1066            })
1067            .unwrap_or_default();
1068        accessors.sort_by(|a, b| summary_id(a).cmp(summary_id(b)));
1069        let (page, next) = paginate(accessors, q)?;
1070        let mut out = json!({ "Accessors": page });
1071        if let Some(n) = next {
1072            out["NextToken"] = json!(n);
1073        }
1074        ok(out)
1075    }
1076
1077    // ----------------------------- Invitations --------------------------
1078
1079    fn list_invitations(
1080        &self,
1081        ctx: &Ctx,
1082        q: &[(String, String)],
1083    ) -> Result<AwsResponse, AwsServiceError> {
1084        validate_list_query(q, 100, &[])?;
1085        let guard = self.state.read();
1086        let mut invitations: Vec<Value> = guard
1087            .get(&ctx.account)
1088            .map(|d| d.invitations.values().cloned().collect())
1089            .unwrap_or_default();
1090        invitations.sort_by(|a, b| {
1091            a.get("InvitationId")
1092                .and_then(Value::as_str)
1093                .unwrap_or("")
1094                .cmp(b.get("InvitationId").and_then(Value::as_str).unwrap_or(""))
1095        });
1096        let (page, next) = paginate(invitations, q)?;
1097        let mut out = json!({ "Invitations": page });
1098        if let Some(n) = next {
1099            out["NextToken"] = json!(n);
1100        }
1101        ok(out)
1102    }
1103
1104    fn reject_invitation(
1105        &self,
1106        ctx: &Ctx,
1107        invitation_id: &str,
1108    ) -> Result<AwsResponse, AwsServiceError> {
1109        let mut guard = self.state.write();
1110        let data = guard.get_or_create(&ctx.account);
1111        let Some(inv) = data
1112            .invitations
1113            .get_mut(invitation_id)
1114            .and_then(Value::as_object_mut)
1115        else {
1116            return Err(not_found(&format!(
1117                "Invitation {invitation_id} was not found."
1118            )));
1119        };
1120        let status = inv.get("Status").and_then(Value::as_str).unwrap_or("");
1121        if status != "PENDING" {
1122            return Err(illegal_action(&format!(
1123                "Invitation {invitation_id} is not pending (status {status})."
1124            )));
1125        }
1126        inv.insert("Status".into(), json!("REJECTED"));
1127        ok_empty()
1128    }
1129
1130    // -------------------------------- Tags ------------------------------
1131
1132    fn tag_resource(
1133        &self,
1134        ctx: &Ctx,
1135        arn: &str,
1136        body: &Value,
1137    ) -> Result<AwsResponse, AwsServiceError> {
1138        check_arn(arn)?;
1139        let mut guard = self.state.write();
1140        let data = guard.get_or_create(&ctx.account);
1141        let entry = data.tags.entry(arn.to_string()).or_default();
1142        if let Some(tags) = body.get("Tags").and_then(Value::as_object) {
1143            for (k, v) in tags {
1144                if let Some(s) = v.as_str() {
1145                    entry.insert(k.clone(), s.to_string());
1146                }
1147            }
1148        }
1149        ok_empty()
1150    }
1151
1152    fn untag_resource(
1153        &self,
1154        ctx: &Ctx,
1155        arn: &str,
1156        q: &[(String, String)],
1157    ) -> Result<AwsResponse, AwsServiceError> {
1158        check_arn(arn)?;
1159        let mut guard = self.state.write();
1160        let data = guard.get_or_create(&ctx.account);
1161        if let Some(entry) = data.tags.get_mut(arn) {
1162            for (k, v) in q {
1163                if k == "tagKeys" {
1164                    entry.remove(v);
1165                }
1166            }
1167            if entry.is_empty() {
1168                data.tags.remove(arn);
1169            }
1170        }
1171        ok_empty()
1172    }
1173
1174    fn list_tags(&self, ctx: &Ctx, arn: &str) -> Result<AwsResponse, AwsServiceError> {
1175        check_arn(arn)?;
1176        let guard = self.state.read();
1177        let tags = guard
1178            .get(&ctx.account)
1179            .and_then(|d| d.tags.get(arn))
1180            .cloned()
1181            .unwrap_or_default();
1182        ok(json!({ "Tags": tags }))
1183    }
1184}
1185
1186// ============================= builders ==============================
1187
1188/// Build a `Member` wire object. Fabric members carry a CA endpoint + admin
1189/// username in their framework attributes.
1190fn build_member(
1191    ctx: &Ctx,
1192    network_id: &str,
1193    member_id: &str,
1194    config: &Value,
1195    now: &str,
1196    fabric: bool,
1197) -> Value {
1198    let admin_username = config
1199        .get("FrameworkConfiguration")
1200        .and_then(|f| f.get("Fabric"))
1201        .and_then(|f| f.get("AdminUsername"))
1202        .and_then(Value::as_str)
1203        .unwrap_or("admin")
1204        .to_string();
1205    let mut m = Map::new();
1206    m.insert("NetworkId".into(), json!(network_id));
1207    m.insert("Id".into(), json!(member_id));
1208    m.insert(
1209        "Name".into(),
1210        json!(config.get("Name").and_then(Value::as_str).unwrap_or("")),
1211    );
1212    m.insert("Status".into(), json!("CREATING"));
1213    m.insert("CreationDate".into(), json!(now));
1214    m.insert(
1215        "Arn".into(),
1216        json!(shared::member_arn(&ctx.region, &ctx.account, member_id)),
1217    );
1218    if let Some(d) = config.get("Description") {
1219        m.insert("Description".into(), d.clone());
1220    }
1221    if let Some(lpc) = config.get("LogPublishingConfiguration") {
1222        m.insert("LogPublishingConfiguration".into(), lpc.clone());
1223    }
1224    if let Some(kms) = config.get("KmsKeyArn") {
1225        m.insert("KmsKeyArn".into(), kms.clone());
1226    }
1227    if fabric {
1228        m.insert(
1229            "FrameworkAttributes".into(),
1230            json!({
1231                "Fabric": {
1232                    "AdminUsername": admin_username,
1233                    "CaEndpoint": shared::fabric_ca_endpoint(member_id, &ctx.region),
1234                }
1235            }),
1236        );
1237    }
1238    Value::Object(m)
1239}
1240
1241/// Build a `Node` wire object, deriving the framework-specific endpoints.
1242fn build_node(
1243    ctx: &Ctx,
1244    network_id: &str,
1245    member_id: Option<&str>,
1246    node_id: &str,
1247    config: &Value,
1248    framework: &str,
1249    now: &str,
1250) -> Value {
1251    let mut n = Map::new();
1252    n.insert("NetworkId".into(), json!(network_id));
1253    if let Some(mid) = member_id {
1254        n.insert("MemberId".into(), json!(mid));
1255    }
1256    n.insert("Id".into(), json!(node_id));
1257    n.insert(
1258        "InstanceType".into(),
1259        json!(config
1260            .get("InstanceType")
1261            .and_then(Value::as_str)
1262            .unwrap_or("")),
1263    );
1264    if let Some(az) = config.get("AvailabilityZone") {
1265        n.insert("AvailabilityZone".into(), az.clone());
1266    } else {
1267        n.insert("AvailabilityZone".into(), json!(format!("{}a", ctx.region)));
1268    }
1269    if let Some(sdb) = config.get("StateDB") {
1270        n.insert("StateDB".into(), sdb.clone());
1271    }
1272    n.insert("Status".into(), json!("CREATING"));
1273    n.insert("CreationDate".into(), json!(now));
1274    n.insert(
1275        "Arn".into(),
1276        json!(shared::node_arn(&ctx.region, &ctx.account, node_id)),
1277    );
1278    if let Some(lpc) = config.get("LogPublishingConfiguration") {
1279        n.insert("LogPublishingConfiguration".into(), lpc.clone());
1280    }
1281    let attrs = if framework == "ETHEREUM" {
1282        json!({
1283            "Ethereum": {
1284                "HttpEndpoint": shared::ethereum_http_endpoint(node_id, &ctx.region),
1285                "WebSocketEndpoint": shared::ethereum_ws_endpoint(node_id, &ctx.region),
1286            }
1287        })
1288    } else {
1289        json!({
1290            "Fabric": {
1291                "PeerEndpoint": shared::fabric_peer_endpoint(node_id, &ctx.region),
1292                "PeerEventEndpoint": shared::fabric_peer_event_endpoint(node_id, &ctx.region),
1293            }
1294        })
1295    };
1296    n.insert("FrameworkAttributes".into(), attrs);
1297    Value::Object(n)
1298}
1299
1300/// Build an `Invitation` wire object for a materialised proposal invitation.
1301fn build_invitation(region: &str, principal: &str, network: &Value) -> Value {
1302    let id = shared::new_invitation_id();
1303    let now = shared::iso_now();
1304    let expiration = (chrono::Utc::now() + chrono::Duration::days(7))
1305        .format("%Y-%m-%dT%H:%M:%S%.3fZ")
1306        .to_string();
1307    json!({
1308        "InvitationId": id,
1309        "CreationDate": now,
1310        "ExpirationDate": expiration,
1311        "Status": "PENDING",
1312        "NetworkSummary": network_summary(network),
1313        "Arn": shared::invitation_arn(region, principal, &id),
1314    })
1315}
1316
1317/// The network-level `FrameworkAttributes` (Fabric ordering endpoint + edition,
1318/// or Ethereum chain id).
1319fn network_framework_attributes(
1320    framework: &str,
1321    network_id: &str,
1322    body: &Value,
1323    region: &str,
1324) -> Value {
1325    if framework == "ETHEREUM" {
1326        // Ethereum network chain id is derived from the framework version.
1327        let chain_id = match body.get("FrameworkVersion").and_then(Value::as_str) {
1328            Some("1.0") | Some("mainnet") => "1",
1329            _ => "1",
1330        };
1331        json!({ "Ethereum": { "ChainId": chain_id } })
1332    } else {
1333        let edition = body
1334            .get("FrameworkConfiguration")
1335            .and_then(|f| f.get("Fabric"))
1336            .and_then(|f| f.get("Edition"))
1337            .and_then(Value::as_str)
1338            .unwrap_or("STARTER");
1339        json!({
1340            "Fabric": {
1341                "OrderingServiceEndpoint": shared::fabric_ordering_endpoint(network_id, region),
1342                "Edition": edition,
1343            }
1344        })
1345    }
1346}
1347
1348// ============================= summaries =============================
1349
1350fn network_summary(n: &Value) -> Value {
1351    let mut s = Map::new();
1352    for k in [
1353        "Id",
1354        "Name",
1355        "Description",
1356        "Framework",
1357        "FrameworkVersion",
1358        "Status",
1359        "CreationDate",
1360        "Arn",
1361    ] {
1362        if let Some(v) = n.get(k) {
1363            s.insert(k.to_string(), v.clone());
1364        }
1365    }
1366    Value::Object(s)
1367}
1368
1369fn member_summary(m: &Value, account: &str, region: &str) -> Value {
1370    let id = m.get("Id").and_then(Value::as_str).unwrap_or("");
1371    let mut s = Map::new();
1372    for k in ["Id", "Name", "Description", "Status", "CreationDate"] {
1373        if let Some(v) = m.get(k) {
1374            s.insert(k.to_string(), v.clone());
1375        }
1376    }
1377    s.insert("IsOwned".into(), json!(true));
1378    s.insert("Arn".into(), json!(shared::member_arn(region, account, id)));
1379    Value::Object(s)
1380}
1381
1382fn node_summary(n: &Value) -> Value {
1383    let mut s = Map::new();
1384    for k in [
1385        "Id",
1386        "Status",
1387        "CreationDate",
1388        "AvailabilityZone",
1389        "InstanceType",
1390        "Arn",
1391    ] {
1392        if let Some(v) = n.get(k) {
1393            s.insert(k.to_string(), v.clone());
1394        }
1395    }
1396    Value::Object(s)
1397}
1398
1399fn proposal_summary(p: &Value) -> Value {
1400    let mut s = Map::new();
1401    for k in [
1402        "ProposalId",
1403        "Description",
1404        "ProposedByMemberId",
1405        "ProposedByMemberName",
1406        "Status",
1407        "CreationDate",
1408        "ExpirationDate",
1409        "Arn",
1410    ] {
1411        if let Some(v) = p.get(k) {
1412            s.insert(k.to_string(), v.clone());
1413        }
1414    }
1415    Value::Object(s)
1416}
1417
1418fn accessor_summary(a: &Value) -> Value {
1419    let mut s = Map::new();
1420    for k in ["Id", "Type", "Status", "CreationDate", "Arn", "NetworkType"] {
1421        if let Some(v) = a.get(k) {
1422            s.insert(k.to_string(), v.clone());
1423        }
1424    }
1425    Value::Object(s)
1426}
1427
1428// ============================= voting logic ==========================
1429
1430/// Extract `(ThresholdPercentage, ThresholdComparator)` from a network's voting
1431/// policy, defaulting to a simple majority (50 %, GREATER_THAN).
1432fn threshold_policy(network: Option<&Value>) -> (i64, String) {
1433    let policy = network
1434        .and_then(|n| n.get("VotingPolicy"))
1435        .and_then(|v| v.get("ApprovalThresholdPolicy"));
1436    let threshold = policy
1437        .and_then(|p| p.get("ThresholdPercentage"))
1438        .and_then(Value::as_i64)
1439        .unwrap_or(50);
1440    let comparator = policy
1441        .and_then(|p| p.get("ThresholdComparator"))
1442        .and_then(Value::as_str)
1443        .unwrap_or("GREATER_THAN")
1444        .to_string();
1445    (threshold, comparator)
1446}
1447
1448/// The `ProposalDurationInHours` from a network's voting policy (default 24).
1449fn proposal_duration_hours(network: Option<&Value>) -> i64 {
1450    network
1451        .and_then(|n| n.get("VotingPolicy"))
1452        .and_then(|v| v.get("ApprovalThresholdPolicy"))
1453        .and_then(|p| p.get("ProposalDurationInHours"))
1454        .and_then(Value::as_i64)
1455        .unwrap_or(24)
1456}
1457
1458/// Number of members eligible to vote in a network (not deleted).
1459fn eligible_member_count(data: &ManagedBlockchainData, network_id: &str) -> i64 {
1460    data.members
1461        .values()
1462        .filter(|m| {
1463            m.get("NetworkId").and_then(Value::as_str) == Some(network_id)
1464                && m.get("Status").and_then(Value::as_str) != Some("DELETED")
1465        })
1466        .count() as i64
1467}
1468
1469/// Decide a proposal's status from the current tally. Approves as soon as the
1470/// yes votes meet the threshold of the total electorate; rejects once approval
1471/// is arithmetically impossible even if every outstanding vote were `YES`.
1472fn decide_proposal(
1473    yes: i64,
1474    outstanding: i64,
1475    total: i64,
1476    threshold: i64,
1477    comparator: &str,
1478) -> String {
1479    if total <= 0 {
1480        return "IN_PROGRESS".to_string();
1481    }
1482    let meets = |y: i64| {
1483        let pct = (y as f64) * 100.0 / (total as f64);
1484        match comparator {
1485            "GREATER_THAN_OR_EQUAL_TO" => pct >= threshold as f64,
1486            _ => pct > threshold as f64,
1487        }
1488    };
1489    if meets(yes) {
1490        "APPROVED".to_string()
1491    } else if !meets(yes + outstanding) {
1492        "REJECTED".to_string()
1493    } else {
1494        "IN_PROGRESS".to_string()
1495    }
1496}
1497
1498/// Promote a `CREATING` member/node wire object to `AVAILABLE`. Returns `true`
1499/// on change.
1500fn promote_creating(v: &mut Value) -> bool {
1501    if let Some(obj) = v.as_object_mut() {
1502        if obj.get("Status").and_then(Value::as_str) == Some("CREATING") {
1503            obj.insert("Status".into(), json!("AVAILABLE"));
1504            return true;
1505        }
1506    }
1507    false
1508}
1509
1510// ============================= helpers ==============================
1511
1512fn ok(v: Value) -> Result<AwsResponse, AwsServiceError> {
1513    Ok(AwsResponse::json_value(StatusCode::OK, v))
1514}
1515
1516fn ok_empty() -> Result<AwsResponse, AwsServiceError> {
1517    Ok(AwsResponse::json(StatusCode::OK, "{}"))
1518}
1519
1520fn parse_body(req: &AwsRequest) -> Result<Value, AwsServiceError> {
1521    if req.body.is_empty() {
1522        return Ok(json!({}));
1523    }
1524    serde_json::from_slice(&req.body)
1525        .map_err(|e| invalid_request(&format!("The request body is malformed: {e}")))
1526}
1527
1528fn invalid_request(msg: &str) -> AwsServiceError {
1529    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "InvalidRequestException", msg)
1530}
1531
1532fn not_found(msg: &str) -> AwsServiceError {
1533    AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFoundException", msg)
1534}
1535
1536fn illegal_action(msg: &str) -> AwsServiceError {
1537    AwsServiceError::aws_error(StatusCode::CONFLICT, "IllegalActionException", msg)
1538}
1539
1540/// Reject a path-label ARN that is absent or not an ARN with
1541/// `InvalidRequestException`.
1542fn check_arn(arn: &str) -> Result<(), AwsServiceError> {
1543    if arn.is_empty() || !arn.starts_with("arn:") {
1544        return Err(invalid_request(
1545            "The request failed because it is missing a valid resource ARN.",
1546        ));
1547    }
1548    Ok(())
1549}
1550
1551fn str_or(body: &Value, key: &str, default: &str) -> String {
1552    body.get(key)
1553        .and_then(Value::as_str)
1554        .unwrap_or(default)
1555        .to_string()
1556}
1557
1558/// Copy each named optional member from `body` into `out` verbatim when present
1559/// and non-null.
1560fn echo(out: &mut Map<String, Value>, body: &Value, keys: &[&str]) {
1561    for key in keys {
1562        if let Some(v) = body.get(*key) {
1563            if !v.is_null() {
1564                out.insert((*key).to_string(), v.clone());
1565            }
1566        }
1567    }
1568}
1569
1570/// Whether an object's string member equals a filter (or the filter is `None`).
1571fn opt_eq(v: &Value, key: &str, filter: Option<&str>) -> bool {
1572    match filter {
1573        None => true,
1574        Some(f) => v.get(key).and_then(Value::as_str) == Some(f),
1575    }
1576}
1577
1578fn summary_id(v: &Value) -> &str {
1579    v.get("Id").and_then(Value::as_str).unwrap_or("")
1580}
1581
1582/// Store the create-time `Tags` map (if any) under a resource ARN.
1583fn store_tags(data: &mut ManagedBlockchainData, arn: &str, body: &Value) {
1584    if let Some(tags) = body.get("Tags").and_then(Value::as_object) {
1585        if tags.is_empty() {
1586            return;
1587        }
1588        let entry = data.tags.entry(arn.to_string()).or_default();
1589        for (k, v) in tags {
1590            if let Some(s) = v.as_str() {
1591                entry.insert(k.clone(), s.to_string());
1592            }
1593        }
1594    }
1595}
1596
1597/// Attach the resource's `Tags` (from the ARN-keyed tag store) to a clone of its
1598/// wire object for a read response.
1599fn with_tags(data: &ManagedBlockchainData, arn: &str, obj: &Value) -> Value {
1600    let mut out = obj.clone();
1601    let tags = data.tags.get(arn).cloned().unwrap_or_default();
1602    if let Some(o) = out.as_object_mut() {
1603        o.insert("Tags".into(), json!(tags));
1604    }
1605    out
1606}
1607
1608fn parse_query(raw: &str) -> Vec<(String, String)> {
1609    raw.split('&')
1610        .filter(|p| !p.is_empty())
1611        .map(|pair| {
1612            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
1613            (
1614                percent_decode_str(k).decode_utf8_lossy().into_owned(),
1615                percent_decode_str(v).decode_utf8_lossy().into_owned(),
1616            )
1617        })
1618        .collect()
1619}
1620
1621fn query_one<'a>(q: &'a [(String, String)], key: &str) -> Option<&'a str> {
1622    q.iter()
1623        .find(|(k, _)| k == key)
1624        .map(|(_, v)| v.as_str())
1625        .filter(|v| !v.is_empty())
1626}
1627
1628/// Managed Blockchain resource ids (`ResourceIdString`) are 1..=32 chars. The
1629/// pagination token (`PaginationToken`) is at most 128 chars.
1630const ID_MAX_LEN: usize = 32;
1631const TOKEN_MAX_LEN: usize = 128;
1632
1633pub const NETWORK_STATUS: &[&str] = &[
1634    "CREATING",
1635    "AVAILABLE",
1636    "CREATE_FAILED",
1637    "DELETING",
1638    "DELETED",
1639];
1640pub const MEMBER_STATUS: &[&str] = &[
1641    "CREATING",
1642    "AVAILABLE",
1643    "CREATE_FAILED",
1644    "UPDATING",
1645    "DELETING",
1646    "DELETED",
1647    "INACCESSIBLE_ENCRYPTION_KEY",
1648];
1649pub const NODE_STATUS: &[&str] = &[
1650    "CREATING",
1651    "AVAILABLE",
1652    "UNHEALTHY",
1653    "CREATE_FAILED",
1654    "UPDATING",
1655    "DELETING",
1656    "DELETED",
1657    "FAILED",
1658    "INACCESSIBLE_ENCRYPTION_KEY",
1659];
1660
1661/// Reject a path-label resource id whose length is outside `1..=32`, or which
1662/// still carries an unfilled `{Placeholder}` (an omitted required label), with
1663/// `InvalidRequestException`.
1664fn check_id(id: &str, field: &str) -> Result<(), AwsServiceError> {
1665    let n = id.chars().count();
1666    if !(1..=ID_MAX_LEN).contains(&n) {
1667        return Err(invalid_request(&format!(
1668            "'{field}' length must be between 1 and {ID_MAX_LEN}, got {n}."
1669        )));
1670    }
1671    if id.contains('{') || id.contains('}') {
1672        return Err(invalid_request(&format!(
1673            "'{field}' is not a valid resource id: {id}"
1674        )));
1675    }
1676    Ok(())
1677}
1678
1679/// Validate the common list query params: `maxResults` (within `1..=cap`),
1680/// `nextToken` (<= 128 chars), plus any `(param, allowed-enum)` constraints.
1681fn validate_list_query(
1682    q: &[(String, String)],
1683    cap: i64,
1684    enums: &[(&str, &[&str])],
1685) -> Result<(), AwsServiceError> {
1686    if let Some(v) = query_one(q, "maxResults") {
1687        let n: i64 = v
1688            .parse()
1689            .map_err(|_| invalid_request("maxResults must be an integer."))?;
1690        if n < 1 || n > cap {
1691            return Err(invalid_request(&format!(
1692                "maxResults must be between 1 and {cap}, got {n}."
1693            )));
1694        }
1695    }
1696    if let Some(t) = query_one(q, "nextToken") {
1697        if t.chars().count() > TOKEN_MAX_LEN {
1698            return Err(invalid_request(&format!(
1699                "nextToken must be at most {TOKEN_MAX_LEN} characters."
1700            )));
1701        }
1702    }
1703    for (key, allowed) in enums {
1704        if let Some(v) = query_one(q, key) {
1705            if !allowed.contains(&v) {
1706                return Err(invalid_request(&format!(
1707                    "'{key}' must be one of [{}], got '{v}'.",
1708                    allowed.join(", ")
1709                )));
1710            }
1711        }
1712    }
1713    Ok(())
1714}
1715
1716/// Paginate wire objects using the request's `maxResults` / `nextToken` query
1717/// params (`nextToken` is a plain decimal offset). Range/length validation is
1718/// performed up front by `validate_list_query`.
1719fn paginate(
1720    items: Vec<Value>,
1721    q: &[(String, String)],
1722) -> Result<(Vec<Value>, Option<String>), AwsServiceError> {
1723    let max = query_one(q, "maxResults")
1724        .and_then(|v| v.parse::<usize>().ok())
1725        .unwrap_or(usize::MAX);
1726    let start = query_one(q, "nextToken")
1727        .and_then(|v| v.parse::<usize>().ok())
1728        .unwrap_or(0);
1729    let start = start.min(items.len());
1730    let end = start.saturating_add(max).min(items.len());
1731    let page: Vec<Value> = items.get(start..end).unwrap_or(&[]).to_vec();
1732    let next = if end < items.len() {
1733        Some(end.to_string())
1734    } else {
1735        None
1736    };
1737    Ok((page, next))
1738}
1739
1740#[cfg(test)]
1741mod tests {
1742    use super::*;
1743    use fakecloud_core::multi_account::MultiAccountState;
1744    use parking_lot::RwLock;
1745
1746    fn svc() -> ManagedBlockchainService {
1747        ManagedBlockchainService::new(Arc::new(RwLock::new(MultiAccountState::new(
1748            "000000000000",
1749            "us-east-1",
1750            "",
1751        ))))
1752    }
1753
1754    fn ctx() -> Ctx {
1755        Ctx {
1756            account: "000000000000".to_string(),
1757            region: "us-east-1".to_string(),
1758        }
1759    }
1760
1761    fn body_of(resp: &AwsResponse) -> Value {
1762        serde_json::from_slice(resp.body.expect_bytes()).unwrap()
1763    }
1764
1765    fn expect_err(r: Result<AwsResponse, AwsServiceError>) -> AwsServiceError {
1766        match r {
1767            Ok(_) => panic!("expected an error response"),
1768            Err(e) => e,
1769        }
1770    }
1771
1772    fn fabric_network(s: &ManagedBlockchainService) -> (String, String) {
1773        let resp = s
1774            .create_network(
1775                &ctx(),
1776                &json!({
1777                    "ClientRequestToken": "t",
1778                    "Name": "net",
1779                    "Framework": "HYPERLEDGER_FABRIC",
1780                    "FrameworkVersion": "2.2",
1781                    "FrameworkConfiguration": { "Fabric": { "Edition": "STARTER" } },
1782                    "VotingPolicy": { "ApprovalThresholdPolicy": {
1783                        "ThresholdPercentage": 50,
1784                        "ProposalDurationInHours": 24,
1785                        "ThresholdComparator": "GREATER_THAN"
1786                    } },
1787                    "MemberConfiguration": {
1788                        "Name": "member1",
1789                        "FrameworkConfiguration": { "Fabric": {
1790                            "AdminUsername": "admin", "AdminPassword": "Password123!"
1791                        } }
1792                    },
1793                    "Description": "desc"
1794                }),
1795            )
1796            .unwrap();
1797        let out = body_of(&resp);
1798        (
1799            out["NetworkId"].as_str().unwrap().to_string(),
1800            out["MemberId"].as_str().unwrap().to_string(),
1801        )
1802    }
1803
1804    #[test]
1805    fn create_network_atomically_creates_first_member() {
1806        let s = svc();
1807        let (net_id, member_id) = fabric_network(&s);
1808        assert!(net_id.starts_with("n-"));
1809        assert!(member_id.starts_with("m-"));
1810
1811        // GetNetwork echoes name + description + status AVAILABLE.
1812        let g = body_of(&s.get_network(&ctx(), &net_id).unwrap());
1813        assert_eq!(g["Network"]["Name"], "net");
1814        assert_eq!(g["Network"]["Description"], "desc");
1815        assert_eq!(g["Network"]["Status"], "AVAILABLE");
1816        assert_eq!(
1817            g["Network"]["FrameworkAttributes"]["Fabric"]["Edition"],
1818            "STARTER"
1819        );
1820
1821        // GetMember settles to AVAILABLE on read and carries a CA endpoint.
1822        s.reconcile(&ctx().account);
1823        let m = body_of(&s.get_member(&ctx(), &net_id, &member_id).unwrap());
1824        assert_eq!(m["Member"]["Status"], "AVAILABLE");
1825        assert!(m["Member"]["FrameworkAttributes"]["Fabric"]["CaEndpoint"].is_string());
1826    }
1827
1828    #[test]
1829    fn node_lifecycle() {
1830        let s = svc();
1831        let (net_id, _member) = fabric_network(&s);
1832        let created = body_of(
1833            &s.create_node(
1834                &ctx(),
1835                &net_id,
1836                &json!({
1837                    "ClientRequestToken": "t2",
1838                    "NodeConfiguration": { "InstanceType": "bc.t3.small", "StateDB": "CouchDB" }
1839                }),
1840            )
1841            .unwrap(),
1842        );
1843        let node_id = created["NodeId"].as_str().unwrap().to_string();
1844        assert!(node_id.starts_with("nd-"));
1845        s.reconcile(&ctx().account);
1846        let g = body_of(&s.get_node(&ctx(), &net_id, &node_id).unwrap());
1847        assert_eq!(g["Node"]["Status"], "AVAILABLE");
1848        assert!(g["Node"]["FrameworkAttributes"]["Fabric"]["PeerEndpoint"].is_string());
1849        // Delete flips to DELETED.
1850        s.delete_node(&ctx(), &net_id, &node_id).unwrap();
1851        let g2 = body_of(&s.get_node(&ctx(), &net_id, &node_id).unwrap());
1852        assert_eq!(g2["Node"]["Status"], "DELETED");
1853    }
1854
1855    #[test]
1856    fn proposal_vote_approves_and_materialises_invitation() {
1857        let s = svc();
1858        let (net_id, member_id) = fabric_network(&s);
1859        // Propose inviting our own account so ListInvitations can see it.
1860        let prop = body_of(
1861            &s.create_proposal(
1862                &ctx(),
1863                &net_id,
1864                &json!({
1865                    "ClientRequestToken": "t3",
1866                    "MemberId": member_id,
1867                    "Actions": { "Invitations": [ { "Principal": "000000000000" } ] }
1868                }),
1869            )
1870            .unwrap(),
1871        );
1872        let proposal_id = prop["ProposalId"].as_str().unwrap().to_string();
1873        // One member, threshold 50% GREATER_THAN -> a single YES = 100% approves.
1874        s.vote_on_proposal(
1875            &ctx(),
1876            &net_id,
1877            &proposal_id,
1878            &json!({ "VoterMemberId": member_id, "Vote": "YES" }),
1879        )
1880        .unwrap();
1881        let g = body_of(&s.get_proposal(&ctx(), &net_id, &proposal_id).unwrap());
1882        assert_eq!(g["Proposal"]["Status"], "APPROVED");
1883        assert_eq!(g["Proposal"]["YesVoteCount"], 1);
1884        // Votes are recorded.
1885        let votes = body_of(
1886            &s.list_proposal_votes(&ctx(), &net_id, &proposal_id, &[])
1887                .unwrap(),
1888        );
1889        assert_eq!(votes["ProposalVotes"].as_array().unwrap().len(), 1);
1890        // The invitation was materialised.
1891        let invs = body_of(&s.list_invitations(&ctx(), &[]).unwrap());
1892        assert_eq!(invs["Invitations"].as_array().unwrap().len(), 1);
1893        assert_eq!(invs["Invitations"][0]["Status"], "PENDING");
1894    }
1895
1896    #[test]
1897    fn create_member_requires_pending_invitation_and_existing_network() {
1898        // CreateMember accepted any invitation regardless of status and reused
1899        // it indefinitely, and neither CreateMember nor CreateNode verified the
1900        // network existed (bug-hunt).
1901        let s = svc();
1902        let (net_id, member_id) = fabric_network(&s);
1903        // Materialize a PENDING invitation for our own account.
1904        let prop = body_of(
1905            &s.create_proposal(
1906                &ctx(),
1907                &net_id,
1908                &json!({
1909                    "ClientRequestToken": "tp",
1910                    "MemberId": member_id,
1911                    "Actions": { "Invitations": [ { "Principal": "000000000000" } ] }
1912                }),
1913            )
1914            .unwrap(),
1915        );
1916        let pid = prop["ProposalId"].as_str().unwrap().to_string();
1917        s.vote_on_proposal(
1918            &ctx(),
1919            &net_id,
1920            &pid,
1921            &json!({ "VoterMemberId": member_id, "Vote": "YES" }),
1922        )
1923        .unwrap();
1924        let invs = body_of(&s.list_invitations(&ctx(), &[]).unwrap());
1925        let inv_id = invs["Invitations"][0]["InvitationId"]
1926            .as_str()
1927            .unwrap()
1928            .to_string();
1929
1930        let member_body = json!({
1931            "InvitationId": inv_id,
1932            "MemberConfiguration": {
1933                "Name": "member2",
1934                "FrameworkConfiguration": { "Fabric": {
1935                    "AdminUsername": "admin", "AdminPassword": "Password123!"
1936                } }
1937            }
1938        });
1939
1940        // First join succeeds and consumes the invitation.
1941        let created = body_of(&s.create_member(&ctx(), &net_id, &member_body).unwrap());
1942        assert!(created["MemberId"].as_str().unwrap().starts_with("m-"));
1943
1944        // Reusing the now-ACCEPTED invitation is rejected.
1945        let err = expect_err(s.create_member(&ctx(), &net_id, &member_body));
1946        assert!(format!("{err:?}").contains("InvalidRequestException"));
1947
1948        // CreateMember / CreateNode against a nonexistent network 404.
1949        let err2 = expect_err(s.create_member(&ctx(), "n-DOESNOTEXIST0001", &member_body));
1950        assert!(format!("{err2:?}").contains("ResourceNotFoundException"));
1951        let err3 = expect_err(s.create_node(
1952            &ctx(),
1953            "n-DOESNOTEXIST0001",
1954            &json!({
1955                "ClientRequestToken": "tn",
1956                "NodeConfiguration": { "InstanceType": "bc.t3.small", "StateDB": "CouchDB" }
1957            }),
1958        ));
1959        assert!(format!("{err3:?}").contains("ResourceNotFoundException"));
1960    }
1961
1962    #[test]
1963    fn duplicate_vote_is_illegal_action() {
1964        let s = svc();
1965        let (net_id, member_id) = fabric_network(&s);
1966        let prop = body_of(
1967            &s.create_proposal(
1968                &ctx(),
1969                &net_id,
1970                &json!({
1971                    "ClientRequestToken": "t",
1972                    "MemberId": member_id,
1973                    "Actions": { "Removals": [ { "MemberId": "m-OTHER" } ] }
1974                }),
1975            )
1976            .unwrap(),
1977        );
1978        let pid = prop["ProposalId"].as_str().unwrap().to_string();
1979        s.vote_on_proposal(
1980            &ctx(),
1981            &net_id,
1982            &pid,
1983            &json!({ "VoterMemberId": member_id, "Vote": "YES" }),
1984        )
1985        .unwrap();
1986        // Proposal already APPROVED -> further votes are illegal.
1987        let err = expect_err(s.vote_on_proposal(
1988            &ctx(),
1989            &net_id,
1990            &pid,
1991            &json!({ "VoterMemberId": member_id, "Vote": "NO" }),
1992        ));
1993        assert!(format!("{err:?}").contains("IllegalActionException"));
1994    }
1995
1996    #[test]
1997    fn accessor_lifecycle() {
1998        let s = svc();
1999        let created = body_of(
2000            &s.create_accessor(
2001                &ctx(),
2002                &json!({ "ClientRequestToken": "t", "AccessorType": "BILLING_TOKEN", "NetworkType": "ETHEREUM_MAINNET" }),
2003            )
2004            .unwrap(),
2005        );
2006        let id = created["AccessorId"].as_str().unwrap().to_string();
2007        assert!(created["BillingToken"].is_string());
2008        let g = body_of(&s.get_accessor(&ctx(), &id).unwrap());
2009        assert_eq!(g["Accessor"]["Status"], "AVAILABLE");
2010        assert_eq!(g["Accessor"]["NetworkType"], "ETHEREUM_MAINNET");
2011        s.delete_accessor(&ctx(), &id).unwrap();
2012        let g2 = body_of(&s.get_accessor(&ctx(), &id).unwrap());
2013        assert_eq!(g2["Accessor"]["Status"], "PENDING_DELETION");
2014    }
2015
2016    #[test]
2017    fn list_networks_filters_and_paginates() {
2018        let s = svc();
2019        fabric_network(&s);
2020        fabric_network(&s);
2021        let all = body_of(&s.list_networks(&ctx(), &[]).unwrap());
2022        assert_eq!(all["Networks"].as_array().unwrap().len(), 2);
2023        // Paginate.
2024        let page1 = body_of(
2025            &s.list_networks(&ctx(), &[("maxResults".into(), "1".into())])
2026                .unwrap(),
2027        );
2028        assert_eq!(page1["Networks"].as_array().unwrap().len(), 1);
2029        assert!(page1["NextToken"].is_string());
2030        // Filter by framework.
2031        let eth = body_of(
2032            &s.list_networks(&ctx(), &[("framework".into(), "ETHEREUM".into())])
2033                .unwrap(),
2034        );
2035        assert_eq!(eth["Networks"].as_array().unwrap().len(), 0);
2036    }
2037
2038    #[test]
2039    fn unknown_network_is_resource_not_found() {
2040        let s = svc();
2041        let err = expect_err(s.get_network(&ctx(), "n-NOPE"));
2042        assert!(format!("{err:?}").contains("ResourceNotFoundException"));
2043    }
2044
2045    #[test]
2046    fn tag_untag_round_trips() {
2047        let s = svc();
2048        let (net_id, _m) = fabric_network(&s);
2049        let arn = shared::network_arn(&net_id);
2050        s.tag_resource(&ctx(), &arn, &json!({ "Tags": { "k": "v" } }))
2051            .unwrap();
2052        let listed = body_of(&s.list_tags(&ctx(), &arn).unwrap());
2053        assert_eq!(listed["Tags"]["k"], "v");
2054        s.untag_resource(&ctx(), &arn, &[("tagKeys".into(), "k".into())])
2055            .unwrap();
2056        let after = body_of(&s.list_tags(&ctx(), &arn).unwrap());
2057        assert!(after["Tags"].as_object().unwrap().is_empty());
2058    }
2059}