@wlfi-agent/cli 1.4.16 → 1.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/Cargo.lock +26 -20
  2. package/Cargo.toml +1 -1
  3. package/README.md +61 -28
  4. package/crates/vault-cli-admin/src/io_utils.rs +149 -1
  5. package/crates/vault-cli-admin/src/main.rs +639 -16
  6. package/crates/vault-cli-admin/src/shared_config.rs +18 -18
  7. package/crates/vault-cli-admin/src/tui/token_rpc.rs +190 -3
  8. package/crates/vault-cli-admin/src/tui/utils.rs +59 -0
  9. package/crates/vault-cli-admin/src/tui.rs +1205 -120
  10. package/crates/vault-cli-agent/Cargo.toml +1 -0
  11. package/crates/vault-cli-agent/src/io_utils.rs +163 -2
  12. package/crates/vault-cli-agent/src/main.rs +648 -32
  13. package/crates/vault-cli-daemon/Cargo.toml +4 -0
  14. package/crates/vault-cli-daemon/src/main.rs +617 -67
  15. package/crates/vault-cli-daemon/src/relay_sync.rs +776 -4
  16. package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +5 -0
  17. package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +32 -1
  18. package/crates/vault-daemon/src/persistence.rs +637 -100
  19. package/crates/vault-daemon/src/tests.rs +1013 -3
  20. package/crates/vault-daemon/src/tests_parts/part2.rs +99 -0
  21. package/crates/vault-daemon/src/tests_parts/part4.rs +11 -7
  22. package/crates/vault-domain/src/nonce.rs +4 -0
  23. package/crates/vault-domain/src/tests.rs +616 -0
  24. package/crates/vault-policy/src/engine.rs +55 -32
  25. package/crates/vault-policy/src/tests.rs +195 -0
  26. package/crates/vault-sdk-agent/src/lib.rs +415 -22
  27. package/crates/vault-signer/Cargo.toml +3 -0
  28. package/crates/vault-signer/src/lib.rs +266 -40
  29. package/crates/vault-transport-unix/src/lib.rs +653 -5
  30. package/crates/vault-transport-xpc/src/tests.rs +531 -3
  31. package/crates/vault-transport-xpc/tests/e2e_flow.rs +3 -0
  32. package/dist/cli.cjs +663 -190
  33. package/dist/cli.cjs.map +1 -1
  34. package/package.json +5 -2
  35. package/packages/cache/.turbo/turbo-build.log +53 -52
  36. package/packages/cache/coverage/clover.xml +529 -394
  37. package/packages/cache/coverage/coverage-final.json +2 -2
  38. package/packages/cache/coverage/index.html +21 -21
  39. package/packages/cache/coverage/src/client/index.html +1 -1
  40. package/packages/cache/coverage/src/client/index.ts.html +1 -1
  41. package/packages/cache/coverage/src/errors/index.html +1 -1
  42. package/packages/cache/coverage/src/errors/index.ts.html +12 -12
  43. package/packages/cache/coverage/src/index.html +1 -1
  44. package/packages/cache/coverage/src/index.ts.html +1 -1
  45. package/packages/cache/coverage/src/service/index.html +21 -21
  46. package/packages/cache/coverage/src/service/index.ts.html +769 -313
  47. package/packages/cache/dist/{chunk-QNK6GOTI.js → chunk-KC53LH5Z.js} +35 -2
  48. package/packages/cache/dist/chunk-KC53LH5Z.js.map +1 -0
  49. package/packages/cache/dist/{chunk-QF4XKEIA.cjs → chunk-UVU7VFE3.cjs} +35 -2
  50. package/packages/cache/dist/chunk-UVU7VFE3.cjs.map +1 -0
  51. package/packages/cache/dist/index.cjs +2 -2
  52. package/packages/cache/dist/index.js +1 -1
  53. package/packages/cache/dist/service/index.cjs +2 -2
  54. package/packages/cache/dist/service/index.js +1 -1
  55. package/packages/cache/node_modules/.bin/tsc +2 -2
  56. package/packages/cache/node_modules/.bin/tsserver +2 -2
  57. package/packages/cache/node_modules/.bin/tsup +2 -2
  58. package/packages/cache/node_modules/.bin/tsup-node +2 -2
  59. package/packages/cache/node_modules/.bin/vitest +4 -4
  60. package/packages/cache/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  61. package/packages/cache/src/service/index.test.ts +165 -19
  62. package/packages/cache/src/service/index.ts +38 -1
  63. package/packages/config/.turbo/turbo-build.log +18 -17
  64. package/packages/config/dist/index.cjs +0 -17
  65. package/packages/config/dist/index.cjs.map +1 -1
  66. package/packages/config/src/index.ts +0 -17
  67. package/packages/rpc/.turbo/turbo-build.log +32 -31
  68. package/packages/rpc/dist/index.cjs +0 -17
  69. package/packages/rpc/dist/index.cjs.map +1 -1
  70. package/packages/rpc/src/index.js +1 -0
  71. package/packages/ui/.turbo/turbo-build.log +44 -43
  72. package/packages/ui/dist/components/badge.d.ts +1 -1
  73. package/packages/ui/dist/components/button.d.ts +1 -1
  74. package/packages/ui/node_modules/.bin/tsc +2 -2
  75. package/packages/ui/node_modules/.bin/tsserver +2 -2
  76. package/packages/ui/node_modules/.bin/tsup +2 -2
  77. package/packages/ui/node_modules/.bin/tsup-node +2 -2
  78. package/scripts/install-cli-launcher.mjs +37 -0
  79. package/scripts/install-rust-binaries.mjs +112 -0
  80. package/scripts/run-tests-isolated.mjs +210 -0
  81. package/src/cli.ts +310 -50
  82. package/src/lib/admin-reset.ts +15 -30
  83. package/src/lib/admin-setup.ts +246 -55
  84. package/src/lib/agent-auth-migrate.ts +5 -1
  85. package/src/lib/asset-broadcast.ts +15 -4
  86. package/src/lib/config-amounts.ts +6 -4
  87. package/src/lib/hidden-tty-prompt.js +1 -0
  88. package/src/lib/hidden-tty-prompt.ts +105 -0
  89. package/src/lib/keychain.ts +1 -0
  90. package/src/lib/local-admin-access.ts +4 -29
  91. package/src/lib/rust.ts +129 -33
  92. package/src/lib/signed-tx.ts +1 -0
  93. package/src/lib/sudo.ts +15 -5
  94. package/src/lib/wallet-profile.ts +3 -0
  95. package/src/lib/wallet-setup.ts +52 -0
  96. package/packages/cache/dist/chunk-QF4XKEIA.cjs.map +0 -1
  97. package/packages/cache/dist/chunk-QNK6GOTI.js.map +0 -1
@@ -828,18 +828,121 @@ fn format_time(value: OffsetDateTime) -> Result<String> {
828
828
 
829
829
  #[cfg(test)]
830
830
  mod tests {
831
- use super::{approval_metadata, manual_approval_error_feedback, resolve_relay_daemon_token_with};
831
+ use super::{
832
+ action_name, apply_daemon_auth, approval_metadata, asset_token_address,
833
+ flatten_policy_records, format_time, manual_approval_error_feedback,
834
+ manual_approval_feedback, map_approval_status, normalize_secret, policy_metadata,
835
+ poll_updates, policy_name, process_update, register_snapshot, resolve_relay_daemon_token_with,
836
+ submit_feedback, ProcessedFeedback, RelayEncryptedPayload,
837
+ RelayEncryptedUpdateRecord,
838
+ };
832
839
  use std::collections::BTreeMap;
840
+ use std::collections::BTreeSet;
833
841
  use std::fs;
834
842
  use std::path::Path;
843
+ use std::sync::{Mutex, OnceLock};
835
844
  use std::time::{SystemTime, UNIX_EPOCH};
845
+ use chacha20poly1305::aead::Aead;
846
+ use chacha20poly1305::{KeyInit, XChaCha20Poly1305};
847
+ use reqwest::Client;
836
848
  use time::OffsetDateTime;
849
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
850
+ use tokio::net::TcpListener;
851
+ use tokio::sync::oneshot;
837
852
  use uuid::Uuid;
838
- use vault_daemon::DaemonError;
853
+ use vault_daemon::{DaemonConfig, DaemonError, InMemoryDaemon, RelayRegistrationSnapshot};
839
854
  use vault_domain::{
840
- AgentAction, AssetId, ManualApprovalDecision, ManualApprovalRequest,
841
- ManualApprovalStatus,
855
+ AgentAction, AgentKey, AssetId, BroadcastTx, Eip3009Transfer, EntityScope,
856
+ ManualApprovalDecision, ManualApprovalRequest, ManualApprovalStatus, Permit2Permit,
857
+ PolicyAttachment, PolicyType, RelayConfig, SpendingPolicy,
842
858
  };
859
+ use vault_signer::SoftwareSignerBackend;
860
+
861
+ fn env_lock() -> &'static Mutex<()> {
862
+ static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
863
+ ENV_LOCK.get_or_init(|| Mutex::new(()))
864
+ }
865
+
866
+ #[derive(Debug)]
867
+ struct CapturedHttpRequest {
868
+ request_line: String,
869
+ headers: BTreeMap<String, String>,
870
+ body: String,
871
+ }
872
+
873
+ async fn spawn_single_response_server(
874
+ status_line: &str,
875
+ response_body: &str,
876
+ ) -> (
877
+ String,
878
+ oneshot::Receiver<CapturedHttpRequest>,
879
+ tokio::task::JoinHandle<()>,
880
+ ) {
881
+ let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
882
+ let addr = listener.local_addr().expect("addr");
883
+ let status_line = status_line.to_string();
884
+ let response_body = response_body.to_string();
885
+ let (tx, rx) = oneshot::channel();
886
+
887
+ let handle = tokio::spawn(async move {
888
+ let (mut stream, _) = listener.accept().await.expect("accept");
889
+ let mut buffer = Vec::new();
890
+ let header_end = loop {
891
+ let mut chunk = [0u8; 1024];
892
+ let read = stream.read(&mut chunk).await.expect("read request");
893
+ if read == 0 {
894
+ panic!("connection closed before headers");
895
+ }
896
+ buffer.extend_from_slice(&chunk[..read]);
897
+ if let Some(position) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
898
+ break position + 4;
899
+ }
900
+ };
901
+
902
+ let header_text = String::from_utf8_lossy(&buffer[..header_end]).to_string();
903
+ let mut lines = header_text.split("\r\n");
904
+ let request_line = lines.next().expect("request line").to_string();
905
+ let mut headers = BTreeMap::new();
906
+ let mut content_length = 0usize;
907
+ for line in lines.filter(|line| !line.is_empty()) {
908
+ let (name, value) = line.split_once(':').expect("header");
909
+ let value = value.trim().to_string();
910
+ if name.eq_ignore_ascii_case("content-length") {
911
+ content_length = value.parse::<usize>().expect("content length");
912
+ }
913
+ headers.insert(name.to_ascii_lowercase(), value);
914
+ }
915
+
916
+ let mut body_bytes = buffer[header_end..].to_vec();
917
+ while body_bytes.len() < content_length {
918
+ let mut chunk = vec![0u8; content_length - body_bytes.len()];
919
+ let read = stream.read(&mut chunk).await.expect("read body");
920
+ if read == 0 {
921
+ break;
922
+ }
923
+ body_bytes.extend_from_slice(&chunk[..read]);
924
+ }
925
+ let body = String::from_utf8(body_bytes[..content_length].to_vec()).expect("utf8 body");
926
+
927
+ let response = format!(
928
+ "HTTP/1.1 {status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
929
+ response_body.len(),
930
+ response_body
931
+ );
932
+ stream
933
+ .write_all(response.as_bytes())
934
+ .await
935
+ .expect("write response");
936
+
937
+ let _ = tx.send(CapturedHttpRequest {
938
+ request_line,
939
+ headers,
940
+ body,
941
+ });
942
+ });
943
+
944
+ (format!("http://{}", addr), rx, handle)
945
+ }
843
946
 
844
947
  #[test]
845
948
  fn approval_metadata_includes_admin_reissue_token_and_public_hash_for_pending_requests() {
@@ -892,6 +995,113 @@ mod tests {
892
995
  ))
893
996
  }
894
997
 
998
+ fn sample_address(value: &str) -> vault_domain::EvmAddress {
999
+ value.parse().expect("address")
1000
+ }
1001
+
1002
+ fn sample_policy(policy_type: PolicyType) -> SpendingPolicy {
1003
+ SpendingPolicy::new(
1004
+ 1,
1005
+ policy_type,
1006
+ 1_000,
1007
+ EntityScope::All,
1008
+ EntityScope::All,
1009
+ EntityScope::All,
1010
+ )
1011
+ .expect("policy")
1012
+ }
1013
+
1014
+ fn sample_manual_request(status: ManualApprovalStatus) -> ManualApprovalRequest {
1015
+ ManualApprovalRequest {
1016
+ id: Uuid::parse_str("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").expect("uuid"),
1017
+ agent_key_id: Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").expect("uuid"),
1018
+ vault_key_id: Uuid::parse_str("cccccccc-cccc-4ccc-8ccc-cccccccccccc").expect("uuid"),
1019
+ request_payload_hash_hex: "11".repeat(32),
1020
+ action: AgentAction::TransferNative {
1021
+ chain_id: 56,
1022
+ to: sample_address("0x2222222222222222222222222222222222222222"),
1023
+ amount_wei: 1,
1024
+ },
1025
+ chain_id: 56,
1026
+ asset: AssetId::NativeEth,
1027
+ recipient: sample_address("0x2222222222222222222222222222222222222222"),
1028
+ amount_wei: 1,
1029
+ created_at: OffsetDateTime::UNIX_EPOCH,
1030
+ updated_at: OffsetDateTime::UNIX_EPOCH,
1031
+ status,
1032
+ triggered_by_policy_ids: vec![Uuid::nil()],
1033
+ completed_at: None,
1034
+ rejection_reason: None,
1035
+ }
1036
+ }
1037
+
1038
+ fn sample_snapshot(relay_url: &str) -> RelayRegistrationSnapshot {
1039
+ RelayRegistrationSnapshot {
1040
+ relay_config: RelayConfig {
1041
+ relay_url: Some(relay_url.to_string()),
1042
+ frontend_url: Some("https://frontend.example".to_string()),
1043
+ daemon_id_hex: "aa".repeat(32),
1044
+ daemon_public_key_hex: "bb".repeat(32),
1045
+ },
1046
+ relay_private_key_hex: "11".repeat(32),
1047
+ vault_public_key_hex: Some("04".repeat(33)),
1048
+ ethereum_address: Some("0x9999999999999999999999999999999999999999".to_string()),
1049
+ policies: vec![sample_policy(PolicyType::PerTxMaxSpending)],
1050
+ agent_keys: vec![AgentKey {
1051
+ id: Uuid::parse_str("dddddddd-dddd-4ddd-8ddd-dddddddddddd").expect("uuid"),
1052
+ vault_key_id: Uuid::parse_str("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee").expect("uuid"),
1053
+ policies: PolicyAttachment::AllPolicies,
1054
+ created_at: OffsetDateTime::UNIX_EPOCH,
1055
+ }],
1056
+ manual_approval_requests: vec![sample_manual_request(ManualApprovalStatus::Pending)],
1057
+ }
1058
+ }
1059
+
1060
+ fn encrypt_payload(
1061
+ daemon_public_key_hex: &str,
1062
+ plaintext: &[u8],
1063
+ ) -> RelayEncryptedPayload {
1064
+ let public_bytes = hex::decode(daemon_public_key_hex).expect("decode daemon public key");
1065
+ let peer_public = x25519_dalek::PublicKey::from(
1066
+ <[u8; 32]>::try_from(public_bytes.as_slice()).expect("public key bytes"),
1067
+ );
1068
+ let secret = x25519_dalek::StaticSecret::from([7u8; 32]);
1069
+ let shared_secret = secret.diffie_hellman(&peer_public);
1070
+ let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into());
1071
+ let nonce = [9u8; 24];
1072
+ let ciphertext = cipher
1073
+ .encrypt(chacha20poly1305::XNonce::from_slice(&nonce), plaintext)
1074
+ .expect("encrypt payload");
1075
+
1076
+ RelayEncryptedPayload {
1077
+ algorithm: "x25519-xchacha20poly1305-v1".to_string(),
1078
+ ciphertext_base64: hex::encode(ciphertext),
1079
+ encapsulated_key_base64: hex::encode(x25519_dalek::PublicKey::from(&secret).as_bytes()),
1080
+ nonce_base64: hex::encode(nonce),
1081
+ }
1082
+ }
1083
+
1084
+ fn sample_update_record(
1085
+ daemon_id: &str,
1086
+ payload: RelayEncryptedPayload,
1087
+ ) -> RelayEncryptedUpdateRecord {
1088
+ RelayEncryptedUpdateRecord {
1089
+ claim_token: Some("claim-token".to_string()),
1090
+ daemon_id: daemon_id.to_string(),
1091
+ payload,
1092
+ target_approval_request_id: None,
1093
+ r#type: "manual_approval_decision".to_string(),
1094
+ update_id: "update-1".to_string(),
1095
+ }
1096
+ }
1097
+
1098
+ fn test_runtime() -> tokio::runtime::Runtime {
1099
+ tokio::runtime::Builder::new_current_thread()
1100
+ .enable_all()
1101
+ .build()
1102
+ .expect("runtime")
1103
+ }
1104
+
895
1105
  #[test]
896
1106
  fn resolve_relay_daemon_token_prefers_explicit_env_value() {
897
1107
  let env = BTreeMap::from([
@@ -1011,4 +1221,566 @@ mod tests {
1011
1221
  .is_some_and(|message| message.contains("already Rejected"))
1012
1222
  );
1013
1223
  }
1224
+
1225
+ #[test]
1226
+ fn manual_approval_feedback_and_reject_error_feedback_cover_remaining_paths() {
1227
+ let approval_request_id =
1228
+ Uuid::parse_str("dddddddd-dddd-4ddd-8ddd-dddddddddddd").expect("uuid");
1229
+
1230
+ let feedback = manual_approval_feedback(
1231
+ approval_request_id,
1232
+ ManualApprovalStatus::Approved,
1233
+ Some(" "),
1234
+ "done".to_string(),
1235
+ );
1236
+ assert_eq!(feedback.status, "applied");
1237
+ assert_eq!(feedback.message.as_deref(), Some("done"));
1238
+ assert!(
1239
+ !feedback
1240
+ .details
1241
+ .as_ref()
1242
+ .expect("details")
1243
+ .contains_key("note")
1244
+ );
1245
+
1246
+ let reject_feedback = manual_approval_error_feedback(
1247
+ approval_request_id,
1248
+ ManualApprovalDecision::Reject,
1249
+ Some("because"),
1250
+ &DaemonError::Transport("boom".to_string()),
1251
+ );
1252
+ assert_eq!(reject_feedback.status, "rejected");
1253
+ assert!(reject_feedback.details.is_none());
1254
+ assert_eq!(reject_feedback.message.as_deref(), Some("transport error: boom"));
1255
+ }
1256
+
1257
+ #[test]
1258
+ fn normalize_secret_trims_and_rejects_blank_values() {
1259
+ assert_eq!(normalize_secret(" token ").as_deref(), Some("token"));
1260
+ assert_eq!(normalize_secret("\n\t "), None);
1261
+ }
1262
+
1263
+ #[test]
1264
+ fn policy_name_covers_all_policy_types() {
1265
+ assert_eq!(
1266
+ policy_name(&sample_policy(PolicyType::DailyMaxSpending)),
1267
+ "daily_max_spending"
1268
+ );
1269
+ assert_eq!(
1270
+ policy_name(&sample_policy(PolicyType::DailyMaxTxCount)),
1271
+ "daily_max_tx_count"
1272
+ );
1273
+ assert_eq!(
1274
+ policy_name(&sample_policy(PolicyType::WeeklyMaxSpending)),
1275
+ "weekly_max_spending"
1276
+ );
1277
+ assert_eq!(
1278
+ policy_name(&sample_policy(PolicyType::PerTxMaxSpending)),
1279
+ "per_tx_max_spending"
1280
+ );
1281
+ assert_eq!(
1282
+ policy_name(&sample_policy(PolicyType::PerTxMaxFeePerGas)),
1283
+ "per_tx_max_fee_per_gas"
1284
+ );
1285
+ assert_eq!(
1286
+ policy_name(&sample_policy(PolicyType::PerTxMaxPriorityFeePerGas)),
1287
+ "per_tx_max_priority_fee_per_gas"
1288
+ );
1289
+ assert_eq!(
1290
+ policy_name(&sample_policy(PolicyType::PerTxMaxCalldataBytes)),
1291
+ "per_tx_max_calldata_bytes"
1292
+ );
1293
+ assert_eq!(
1294
+ policy_name(&sample_policy(PolicyType::PerChainMaxGasSpend)),
1295
+ "per_chain_max_gas_spend"
1296
+ );
1297
+ assert_eq!(
1298
+ policy_name(&sample_policy(PolicyType::ManualApproval)),
1299
+ "manual_approval"
1300
+ );
1301
+ }
1302
+
1303
+ #[test]
1304
+ fn action_name_covers_all_agent_actions() {
1305
+ assert_eq!(
1306
+ action_name(&AgentAction::Approve {
1307
+ chain_id: 1,
1308
+ token: sample_address("0x1111111111111111111111111111111111111111"),
1309
+ spender: sample_address("0x2222222222222222222222222222222222222222"),
1310
+ amount_wei: 1,
1311
+ }),
1312
+ "approve"
1313
+ );
1314
+ assert_eq!(
1315
+ action_name(&AgentAction::Transfer {
1316
+ chain_id: 1,
1317
+ token: sample_address("0x1111111111111111111111111111111111111111"),
1318
+ to: sample_address("0x2222222222222222222222222222222222222222"),
1319
+ amount_wei: 1,
1320
+ }),
1321
+ "transfer"
1322
+ );
1323
+ assert_eq!(
1324
+ action_name(&AgentAction::TransferNative {
1325
+ chain_id: 1,
1326
+ to: sample_address("0x2222222222222222222222222222222222222222"),
1327
+ amount_wei: 1,
1328
+ }),
1329
+ "transfer_native"
1330
+ );
1331
+ assert_eq!(
1332
+ action_name(&AgentAction::Permit2Permit {
1333
+ permit: Permit2Permit {
1334
+ chain_id: 1,
1335
+ permit2_contract: sample_address(
1336
+ "0x4444444444444444444444444444444444444444"
1337
+ ),
1338
+ token: sample_address("0x1111111111111111111111111111111111111111"),
1339
+ spender: sample_address("0x2222222222222222222222222222222222222222"),
1340
+ amount_wei: 1,
1341
+ expiration: 1,
1342
+ nonce: 1,
1343
+ sig_deadline: 1,
1344
+ },
1345
+ }),
1346
+ "permit2_permit"
1347
+ );
1348
+ assert_eq!(
1349
+ action_name(&AgentAction::Eip3009TransferWithAuthorization {
1350
+ authorization: Eip3009Transfer {
1351
+ chain_id: 1,
1352
+ token: sample_address("0x1111111111111111111111111111111111111111"),
1353
+ token_name: "USD Coin".to_string(),
1354
+ token_version: Some("2".to_string()),
1355
+ from: sample_address("0x2222222222222222222222222222222222222222"),
1356
+ to: sample_address("0x3333333333333333333333333333333333333333"),
1357
+ amount_wei: 1,
1358
+ valid_after: 1,
1359
+ valid_before: 2,
1360
+ nonce_hex: format!("0x{}", "07".repeat(32)),
1361
+ },
1362
+ }),
1363
+ "eip3009_transfer_with_authorization"
1364
+ );
1365
+ assert_eq!(
1366
+ action_name(&AgentAction::Eip3009ReceiveWithAuthorization {
1367
+ authorization: Eip3009Transfer {
1368
+ chain_id: 1,
1369
+ token: sample_address("0x1111111111111111111111111111111111111111"),
1370
+ token_name: "USD Coin".to_string(),
1371
+ token_version: None,
1372
+ from: sample_address("0x2222222222222222222222222222222222222222"),
1373
+ to: sample_address("0x3333333333333333333333333333333333333333"),
1374
+ amount_wei: 1,
1375
+ valid_after: 1,
1376
+ valid_before: 2,
1377
+ nonce_hex: format!("0x{}", "09".repeat(32)),
1378
+ },
1379
+ }),
1380
+ "eip3009_receive_with_authorization"
1381
+ );
1382
+ assert_eq!(
1383
+ action_name(&AgentAction::BroadcastTx {
1384
+ tx: BroadcastTx {
1385
+ chain_id: 1,
1386
+ nonce: 1,
1387
+ to: sample_address("0x2222222222222222222222222222222222222222"),
1388
+ value_wei: 0,
1389
+ data_hex: "0x".to_string(),
1390
+ gas_limit: 21_000,
1391
+ max_fee_per_gas_wei: 2,
1392
+ max_priority_fee_per_gas_wei: 1,
1393
+ tx_type: 0x02,
1394
+ delegation_enabled: false,
1395
+ },
1396
+ }),
1397
+ "broadcast_tx"
1398
+ );
1399
+ }
1400
+
1401
+ #[test]
1402
+ fn map_approval_status_covers_all_statuses() {
1403
+ assert_eq!(map_approval_status(ManualApprovalStatus::Pending), "pending");
1404
+ assert_eq!(map_approval_status(ManualApprovalStatus::Approved), "approved");
1405
+ assert_eq!(map_approval_status(ManualApprovalStatus::Rejected), "rejected");
1406
+ assert_eq!(map_approval_status(ManualApprovalStatus::Completed), "completed");
1407
+ }
1408
+
1409
+ #[test]
1410
+ fn asset_token_address_covers_native_and_erc20_assets() {
1411
+ assert_eq!(asset_token_address(&AssetId::NativeEth), None);
1412
+ assert_eq!(
1413
+ asset_token_address(&AssetId::Erc20(sample_address(
1414
+ "0x1111111111111111111111111111111111111111"
1415
+ )))
1416
+ .as_deref(),
1417
+ Some("0x1111111111111111111111111111111111111111")
1418
+ );
1419
+ }
1420
+
1421
+ #[test]
1422
+ fn flatten_policy_records_expands_scopes_and_skips_disabled_policies() {
1423
+ let mut policy = SpendingPolicy::new_manual_approval(
1424
+ 5,
1425
+ 10,
1426
+ 100,
1427
+ EntityScope::Set(BTreeSet::from([
1428
+ sample_address("0x1111111111111111111111111111111111111111"),
1429
+ sample_address("0x2222222222222222222222222222222222222222"),
1430
+ ])),
1431
+ EntityScope::Set(BTreeSet::from([
1432
+ AssetId::NativeEth,
1433
+ AssetId::Erc20(sample_address("0x3333333333333333333333333333333333333333")),
1434
+ ])),
1435
+ EntityScope::Set(BTreeSet::from([1, 10])),
1436
+ )
1437
+ .expect("policy");
1438
+ let mut disabled = sample_policy(PolicyType::PerTxMaxSpending);
1439
+ disabled.enabled = false;
1440
+ policy.id = Uuid::parse_str("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").expect("uuid");
1441
+
1442
+ let records = flatten_policy_records(&[policy.clone(), disabled], "2026-03-11T00:00:00Z");
1443
+ assert_eq!(records.len(), 8);
1444
+ assert!(records.iter().all(|record| record.policy_id == policy.id.to_string()));
1445
+ assert!(records.iter().all(|record| record.requires_manual_approval));
1446
+ assert!(records.iter().all(|record| record.scope == "override"));
1447
+ assert!(records.iter().any(|record| record.token_address.is_none()));
1448
+ assert!(records.iter().any(|record| {
1449
+ record.token_address.as_deref()
1450
+ == Some("0x3333333333333333333333333333333333333333")
1451
+ }));
1452
+ assert!(records.iter().all(|record| record.metadata.is_some()));
1453
+ }
1454
+
1455
+ #[test]
1456
+ fn policy_metadata_covers_scope_specific_fields() {
1457
+ let policy = SpendingPolicy::new_manual_approval(
1458
+ 1,
1459
+ 10,
1460
+ 100,
1461
+ EntityScope::Set(BTreeSet::from([sample_address(
1462
+ "0x1111111111111111111111111111111111111111",
1463
+ )])),
1464
+ EntityScope::Set(BTreeSet::from([AssetId::Erc20(sample_address(
1465
+ "0x3333333333333333333333333333333333333333",
1466
+ ))])),
1467
+ EntityScope::Set(BTreeSet::from([1])),
1468
+ )
1469
+ .expect("policy");
1470
+
1471
+ let metadata = policy_metadata(
1472
+ &policy,
1473
+ Some(&AssetId::Erc20(sample_address(
1474
+ "0x3333333333333333333333333333333333333333",
1475
+ ))),
1476
+ Some(1),
1477
+ "override",
1478
+ );
1479
+ assert_eq!(metadata.get("policyType").map(String::as_str), Some("manual_approval"));
1480
+ assert_eq!(metadata.get("scope").map(String::as_str), Some("override"));
1481
+ assert_eq!(metadata.get("recipientScope").map(String::as_str), Some("set"));
1482
+ assert_eq!(metadata.get("assetScope").map(String::as_str), Some("set"));
1483
+ assert_eq!(metadata.get("networkScope").map(String::as_str), Some("set"));
1484
+ assert_eq!(metadata.get("asset").map(String::as_str), Some("erc20:0x3333333333333333333333333333333333333333"));
1485
+ assert_eq!(metadata.get("chainId").map(String::as_str), Some("1"));
1486
+ }
1487
+
1488
+ #[test]
1489
+ fn approval_metadata_omits_capability_for_non_pending_requests() {
1490
+ let mut request = ManualApprovalRequest {
1491
+ id: Uuid::parse_str("cccccccc-cccc-4ccc-8ccc-cccccccccccc").expect("uuid"),
1492
+ agent_key_id: Uuid::nil(),
1493
+ vault_key_id: Uuid::nil(),
1494
+ request_payload_hash_hex: "22".repeat(32),
1495
+ action: AgentAction::TransferNative {
1496
+ chain_id: 56,
1497
+ to: sample_address("0x2222222222222222222222222222222222222222"),
1498
+ amount_wei: 1,
1499
+ },
1500
+ chain_id: 56,
1501
+ asset: AssetId::NativeEth,
1502
+ recipient: sample_address("0x2222222222222222222222222222222222222222"),
1503
+ amount_wei: 1,
1504
+ created_at: OffsetDateTime::UNIX_EPOCH,
1505
+ updated_at: OffsetDateTime::UNIX_EPOCH,
1506
+ status: ManualApprovalStatus::Completed,
1507
+ triggered_by_policy_ids: vec![Uuid::nil()],
1508
+ completed_at: Some(OffsetDateTime::UNIX_EPOCH),
1509
+ rejection_reason: None,
1510
+ };
1511
+
1512
+ let metadata = approval_metadata(&request, &"11".repeat(32)).expect("metadata");
1513
+ assert_eq!(
1514
+ metadata.get("triggeredPolicyIds").map(String::as_str),
1515
+ Some("00000000-0000-0000-0000-000000000000")
1516
+ );
1517
+ assert_eq!(metadata.get("asset").map(String::as_str), Some("native_eth"));
1518
+ assert!(!metadata.contains_key("approvalCapabilityToken"));
1519
+ assert!(!metadata.contains_key("approvalCapabilityHash"));
1520
+
1521
+ request.status = ManualApprovalStatus::Rejected;
1522
+ let metadata = approval_metadata(&request, " ").expect("metadata");
1523
+ assert!(!metadata.contains_key("approvalCapabilityToken"));
1524
+ }
1525
+
1526
+ #[test]
1527
+ fn format_time_renders_rfc3339() {
1528
+ let rendered = format_time(OffsetDateTime::UNIX_EPOCH).expect("format time");
1529
+ assert_eq!(rendered, "1970-01-01T00:00:00Z");
1530
+ }
1531
+
1532
+ #[test]
1533
+ fn apply_daemon_auth_adds_header_when_token_env_is_present() {
1534
+ let _guard = env_lock().lock().expect("env lock");
1535
+ std::env::set_var("WLFI_RELAY_DAEMON_TOKEN", "relay-secret");
1536
+ std::env::remove_var("WLFI_RELAY_DAEMON_TOKEN_FILE");
1537
+
1538
+ let client = Client::new();
1539
+ let request = apply_daemon_auth(client.get("https://relay.example"))
1540
+ .build()
1541
+ .expect("build request");
1542
+ assert_eq!(
1543
+ request
1544
+ .headers()
1545
+ .get("x-relay-daemon-token")
1546
+ .and_then(|value| value.to_str().ok()),
1547
+ Some("relay-secret")
1548
+ );
1549
+
1550
+ std::env::remove_var("WLFI_RELAY_DAEMON_TOKEN");
1551
+ }
1552
+
1553
+ #[tokio::test]
1554
+ async fn register_snapshot_posts_expected_payload_and_header() {
1555
+ let _guard = env_lock().lock().expect("env lock");
1556
+ std::env::set_var("WLFI_RELAY_DAEMON_TOKEN", "relay-secret");
1557
+ std::env::set_var("HOSTNAME", "daemon-host");
1558
+
1559
+ let (base_url, request_rx, handle) = spawn_single_response_server("200 OK", "{}").await;
1560
+ let snapshot = sample_snapshot(&base_url);
1561
+ let client = Client::new();
1562
+
1563
+ register_snapshot(
1564
+ &client,
1565
+ &base_url,
1566
+ "software",
1567
+ "2026-03-11T00:00:00Z",
1568
+ &snapshot,
1569
+ snapshot.ethereum_address.as_deref().expect("eth address"),
1570
+ )
1571
+ .await
1572
+ .expect("register snapshot");
1573
+
1574
+ let captured = request_rx.await.expect("captured request");
1575
+ handle.await.expect("server");
1576
+ assert_eq!(captured.request_line, "POST /v1/daemon/register HTTP/1.1");
1577
+ assert_eq!(
1578
+ captured.headers.get("x-relay-daemon-token").map(String::as_str),
1579
+ Some("relay-secret")
1580
+ );
1581
+ assert!(captured.body.contains("\"daemonId\":\""));
1582
+ assert!(captured.body.contains("\"signerBackend\":\"software\""));
1583
+ assert!(captured.body.contains("\"label\":\"daemon-host\""));
1584
+ assert!(captured.body.contains("\"approvalRequests\""));
1585
+
1586
+ std::env::remove_var("WLFI_RELAY_DAEMON_TOKEN");
1587
+ std::env::remove_var("HOSTNAME");
1588
+ }
1589
+
1590
+ #[tokio::test]
1591
+ async fn poll_updates_posts_expected_request_and_deserializes_response() {
1592
+ let response_body = r#"{"items":[{"claimToken":"claim-token","daemonId":"daemon-1","payload":{"algorithm":"x25519-xchacha20poly1305-v1","ciphertextBase64":"aa","encapsulatedKeyBase64":"bb","nonceBase64":"cc"},"type":"manual_approval_decision","updateId":"update-1"}]}"#;
1593
+ let (base_url, request_rx, handle) =
1594
+ spawn_single_response_server("200 OK", &response_body).await;
1595
+ let client = Client::new();
1596
+
1597
+ let response = poll_updates(&client, &base_url, "daemon-1")
1598
+ .await
1599
+ .expect("poll response");
1600
+ let captured = request_rx.await.expect("captured request");
1601
+ handle.await.expect("server");
1602
+
1603
+ assert_eq!(captured.request_line, "POST /v1/daemon/poll-updates HTTP/1.1");
1604
+ assert!(captured.body.contains("\"daemonId\":\"daemon-1\""));
1605
+ assert!(captured.body.contains("\"leaseSeconds\":30"));
1606
+ assert!(captured.body.contains("\"limit\":25"));
1607
+ assert_eq!(response.items.len(), 1);
1608
+ assert_eq!(response.items[0].daemon_id, "daemon-1");
1609
+ }
1610
+
1611
+ #[tokio::test]
1612
+ async fn submit_feedback_posts_expected_payload() {
1613
+ let (base_url, request_rx, handle) = spawn_single_response_server("200 OK", "{}").await;
1614
+ let client = Client::new();
1615
+
1616
+ submit_feedback(
1617
+ &client,
1618
+ &base_url,
1619
+ "claim-token",
1620
+ "daemon-1",
1621
+ "update-1",
1622
+ ProcessedFeedback {
1623
+ details: Some(BTreeMap::from([(
1624
+ "approvalRequestId".to_string(),
1625
+ "approval-1".to_string(),
1626
+ )])),
1627
+ message: Some("done".to_string()),
1628
+ status: "applied",
1629
+ },
1630
+ )
1631
+ .await
1632
+ .expect("submit feedback");
1633
+
1634
+ let captured = request_rx.await.expect("captured request");
1635
+ handle.await.expect("server");
1636
+ assert_eq!(captured.request_line, "POST /v1/daemon/submit-feedback HTTP/1.1");
1637
+ assert!(captured.body.contains("\"claimToken\":\"claim-token\""));
1638
+ assert!(captured.body.contains("\"status\":\"applied\""));
1639
+ assert!(captured.body.contains("\"approvalRequestId\":\"approval-1\""));
1640
+ }
1641
+
1642
+ #[tokio::test]
1643
+ async fn process_update_rejects_invalid_payload_variants() {
1644
+ let daemon = InMemoryDaemon::new(
1645
+ "vault-password",
1646
+ SoftwareSignerBackend::default(),
1647
+ DaemonConfig::default(),
1648
+ )
1649
+ .expect("daemon");
1650
+ let snapshot = daemon
1651
+ .relay_registration_snapshot()
1652
+ .expect("relay snapshot");
1653
+ let expected_daemon_id = snapshot.relay_config.daemon_id_hex.clone();
1654
+
1655
+ let mismatch = process_update(
1656
+ &daemon,
1657
+ &expected_daemon_id,
1658
+ &RelayEncryptedUpdateRecord {
1659
+ daemon_id: "wrong-daemon".to_string(),
1660
+ ..sample_update_record(
1661
+ &expected_daemon_id,
1662
+ RelayEncryptedPayload {
1663
+ algorithm: "x25519-xchacha20poly1305-v1".to_string(),
1664
+ ciphertext_base64: String::new(),
1665
+ encapsulated_key_base64: String::new(),
1666
+ nonce_base64: String::new(),
1667
+ },
1668
+ )
1669
+ },
1670
+ )
1671
+ .await;
1672
+ assert_eq!(mismatch.status, "rejected");
1673
+ assert!(
1674
+ mismatch
1675
+ .message
1676
+ .as_deref()
1677
+ .is_some_and(|value| value.contains("does not match"))
1678
+ );
1679
+
1680
+ let unsupported = process_update(
1681
+ &daemon,
1682
+ &expected_daemon_id,
1683
+ &RelayEncryptedUpdateRecord {
1684
+ r#type: "other".to_string(),
1685
+ ..sample_update_record(
1686
+ &expected_daemon_id,
1687
+ RelayEncryptedPayload {
1688
+ algorithm: "x25519-xchacha20poly1305-v1".to_string(),
1689
+ ciphertext_base64: String::new(),
1690
+ encapsulated_key_base64: String::new(),
1691
+ nonce_base64: String::new(),
1692
+ },
1693
+ )
1694
+ },
1695
+ )
1696
+ .await;
1697
+ assert_eq!(unsupported.status, "failed");
1698
+ assert!(
1699
+ unsupported
1700
+ .message
1701
+ .as_deref()
1702
+ .is_some_and(|value| value.contains("unsupported relay update type"))
1703
+ );
1704
+
1705
+ let invalid_json_payload = encrypt_payload(
1706
+ &snapshot.relay_config.daemon_public_key_hex,
1707
+ br#"{"not":"a manual approval payload"}"#,
1708
+ );
1709
+ let invalid_json = process_update(
1710
+ &daemon,
1711
+ &expected_daemon_id,
1712
+ &sample_update_record(&expected_daemon_id, invalid_json_payload),
1713
+ )
1714
+ .await;
1715
+ assert_eq!(invalid_json.status, "failed");
1716
+ assert!(
1717
+ invalid_json
1718
+ .message
1719
+ .as_deref()
1720
+ .is_some_and(|value| value.contains("invalid relay update payload"))
1721
+ );
1722
+
1723
+ let wrong_payload_daemon = encrypt_payload(
1724
+ &snapshot.relay_config.daemon_public_key_hex,
1725
+ &serde_json::to_vec(&serde_json::json!({
1726
+ "approvalId": Uuid::nil().to_string(),
1727
+ "daemonId": "wrong-daemon",
1728
+ "decision": "approve",
1729
+ "vaultPassword": "vault-password"
1730
+ }))
1731
+ .expect("json"),
1732
+ );
1733
+ let wrong_payload_daemon = process_update(
1734
+ &daemon,
1735
+ &expected_daemon_id,
1736
+ &sample_update_record(&expected_daemon_id, wrong_payload_daemon),
1737
+ )
1738
+ .await;
1739
+ assert_eq!(wrong_payload_daemon.status, "rejected");
1740
+
1741
+ let target_mismatch_payload = encrypt_payload(
1742
+ &snapshot.relay_config.daemon_public_key_hex,
1743
+ &serde_json::to_vec(&serde_json::json!({
1744
+ "approvalId": Uuid::nil().to_string(),
1745
+ "daemonId": expected_daemon_id,
1746
+ "decision": "approve",
1747
+ "vaultPassword": "vault-password"
1748
+ }))
1749
+ .expect("json"),
1750
+ );
1751
+ let target_mismatch = process_update(
1752
+ &daemon,
1753
+ &expected_daemon_id,
1754
+ &RelayEncryptedUpdateRecord {
1755
+ target_approval_request_id: Some(Uuid::new_v4().to_string()),
1756
+ ..sample_update_record(&expected_daemon_id, target_mismatch_payload)
1757
+ },
1758
+ )
1759
+ .await;
1760
+ assert_eq!(target_mismatch.status, "rejected");
1761
+
1762
+ let invalid_approval_id_payload = encrypt_payload(
1763
+ &snapshot.relay_config.daemon_public_key_hex,
1764
+ &serde_json::to_vec(&serde_json::json!({
1765
+ "approvalId": "not-a-uuid",
1766
+ "daemonId": expected_daemon_id,
1767
+ "decision": "approve",
1768
+ "vaultPassword": "vault-password"
1769
+ }))
1770
+ .expect("json"),
1771
+ );
1772
+ let invalid_approval_id = process_update(
1773
+ &daemon,
1774
+ &expected_daemon_id,
1775
+ &sample_update_record(&expected_daemon_id, invalid_approval_id_payload),
1776
+ )
1777
+ .await;
1778
+ assert_eq!(invalid_approval_id.status, "failed");
1779
+ assert!(
1780
+ invalid_approval_id
1781
+ .message
1782
+ .as_deref()
1783
+ .is_some_and(|value| value.contains("approval_id is not a UUID"))
1784
+ );
1785
+ }
1014
1786
  }