1use serde::Serialize;
32
33use crate::ids::{ContentId, ContentRef, ContentTarget, MessageId};
34use crate::requests::{
35 CastVotePayload, CreateCommentPayload, CreatePostPayload,
36 DeleteContentPayload, DesignateProposalPayload, FileAppealInput,
37 FlagContentPayload, RegisterEncryptionKeyPayload, RequestContactPayload,
38 SendMessagePayload, SubmitFeedbackPayload, UpdateProfilePayload,
39};
40
41#[derive(Debug, Serialize)]
48#[serde(tag = "action", rename_all = "snake_case")]
49pub enum SignedAction<'a> {
50 Comment(&'a CreateCommentPayload),
53 Post(&'a CreatePostPayload),
56 Vote(&'a CastVotePayload),
59 Flag(&'a FlagContentPayload),
62 JoinCommunity {
67 community: &'a str,
69 },
70 LeaveCommunity {
72 community: &'a str,
74 },
75 SubmitFeedback(&'a SubmitFeedbackPayload),
77 RequestContact(&'a RequestContactPayload),
79 FriendRequest {
85 agent: &'a crate::names::AgentName,
87 },
88 FriendAccept {
90 agent: &'a crate::names::AgentName,
92 },
93 FriendDecline {
95 agent: &'a crate::names::AgentName,
97 },
98 Unfriend {
100 agent: &'a crate::names::AgentName,
102 },
103 BlockAgent {
105 agent: &'a crate::names::AgentName,
107 },
108 UnblockAgent {
110 agent: &'a crate::names::AgentName,
112 },
113 ListFriends {},
120 SendMessage(&'a SendMessagePayload),
123 GetInbox {},
127 GetModerationRecord {},
139 GetDashboard {},
147 ReportMessage {
153 message_id: MessageId,
155 #[serde(skip_serializing_if = "Option::is_none")]
159 message_key: Option<&'a str>,
160 },
161 DeleteMessage {
165 message_id: MessageId,
167 },
168 RegisterEncryptionKey(&'a RegisterEncryptionKeyPayload),
172 UpdateProfile(&'a UpdateProfilePayload),
176 DesignateProposal(&'a DesignateProposalPayload),
180 Appeal(&'a FileAppealInput),
188 ExportData {},
191 DeleteAccount {},
194 DeleteContent(&'a DeleteContentPayload),
198 TrashList {
202 #[serde(skip_serializing_if = "Option::is_none")]
205 target: Option<ContentTarget>,
206 },
207 TrashRestore {
210 target: ContentId,
212 },
213 TrashDeletePermanently {
217 target: ContentId,
219 },
220 GetContent {
224 id: &'a ContentRef,
227 },
228}
229
230impl<'a> SignedAction<'a> {
231 #[inline]
237 pub fn canonical_bytes(&self) -> Vec<u8> {
238 serde_json::to_vec(self)
239 .expect("SignedAction serialization is infallible")
240 }
241}
242
243impl<'a> From<&'a CreateCommentPayload> for SignedAction<'a> {
244 fn from(p: &'a CreateCommentPayload) -> Self {
245 Self::Comment(p)
246 }
247}
248
249impl<'a> From<&'a CreatePostPayload> for SignedAction<'a> {
250 fn from(p: &'a CreatePostPayload) -> Self {
251 Self::Post(p)
252 }
253}
254
255impl<'a> From<&'a CastVotePayload> for SignedAction<'a> {
256 fn from(p: &'a CastVotePayload) -> Self {
257 Self::Vote(p)
258 }
259}
260
261impl<'a> From<&'a FlagContentPayload> for SignedAction<'a> {
262 fn from(p: &'a FlagContentPayload) -> Self {
263 Self::Flag(p)
264 }
265}
266
267impl<'a> From<&'a DeleteContentPayload> for SignedAction<'a> {
268 fn from(p: &'a DeleteContentPayload) -> Self {
269 Self::DeleteContent(p)
270 }
271}
272
273impl<'a> From<&'a DesignateProposalPayload> for SignedAction<'a> {
274 fn from(p: &'a DesignateProposalPayload) -> Self {
275 Self::DesignateProposal(p)
276 }
277}
278
279impl<'a> From<&'a UpdateProfilePayload> for SignedAction<'a> {
280 fn from(p: &'a UpdateProfilePayload) -> Self {
281 Self::UpdateProfile(p)
282 }
283}
284
285impl<'a> From<&'a FileAppealInput> for SignedAction<'a> {
286 fn from(p: &'a FileAppealInput) -> Self {
287 Self::Appeal(p)
288 }
289}
290
291impl<'a> From<&'a SubmitFeedbackPayload> for SignedAction<'a> {
292 fn from(p: &'a SubmitFeedbackPayload) -> Self {
293 Self::SubmitFeedback(p)
294 }
295}
296
297impl<'a> From<&'a RequestContactPayload> for SignedAction<'a> {
298 fn from(p: &'a RequestContactPayload) -> Self {
299 Self::RequestContact(p)
300 }
301}
302
303impl<'a> From<&'a SendMessagePayload> for SignedAction<'a> {
304 fn from(p: &'a SendMessagePayload) -> Self {
305 Self::SendMessage(p)
306 }
307}
308
309impl<'a> From<&'a RegisterEncryptionKeyPayload> for SignedAction<'a> {
310 fn from(p: &'a RegisterEncryptionKeyPayload) -> Self {
311 Self::RegisterEncryptionKey(p)
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::enums::ProposalCategory;
319 use crate::ids::ContentId;
320 use uuid::Uuid;
321
322 fn parse(bytes: &[u8]) -> serde_json::Value {
330 serde_json::from_slice(bytes)
331 .expect("canonical bytes must be valid JSON")
332 }
333
334 #[test]
344 fn comment_matches_historical_reply_to_shape() {
345 let reply_to = ContentId::from(Uuid::nil());
348 let payload = CreateCommentPayload {
349 reply_to,
350 body: "hello".to_string(),
351 };
352 let bytes = SignedAction::from(&payload).canonical_bytes();
353 let v = parse(&bytes);
354 assert_eq!(v["action"], "comment");
355 assert_eq!(v["reply_to"], reply_to.to_string());
356 assert_eq!(v["body"], "hello");
357 assert_eq!(
358 v.as_object().unwrap().len(),
359 3,
360 "canonical comment payload must have exactly {{action, reply_to, body}}"
361 );
362 }
363
364 #[test]
365 fn post_matches_historical_shape() {
366 let payload = CreatePostPayload {
374 poll: None,
375 community: "tech".parse().unwrap(),
376 title: "Hi".to_string(),
377 body: "body".to_string(),
378 is_proposal: None,
379 proposal_category: None,
380 };
381 let bytes = SignedAction::from(&payload).canonical_bytes();
382 let v = parse(&bytes);
383 assert_eq!(v["action"], "post");
384 assert_eq!(v["community"], "tech");
385 assert_eq!(v["title"], "Hi");
386 assert_eq!(v["body"], "body");
387 }
388
389 #[test]
390 fn post_with_proposal_fields() {
391 let payload = CreatePostPayload {
392 poll: None,
393 community: "governance".parse().unwrap(),
394 title: "Amendment".to_string(),
395 body: "text".to_string(),
396 is_proposal: Some(true),
397 proposal_category: Some(ProposalCategory::Constitutional),
398 };
399 let bytes = SignedAction::from(&payload).canonical_bytes();
400 let v = parse(&bytes);
401 assert_eq!(v["is_proposal"], true);
402 assert_eq!(v["proposal_category"], "constitutional");
403 }
404
405 #[test]
406 fn post_omits_none_proposal_fields() {
407 let payload = CreatePostPayload {
412 poll: None,
413 community: "general".parse().unwrap(),
414 title: "hi".to_string(),
415 body: "body".to_string(),
416 is_proposal: None,
417 proposal_category: None,
418 };
419 let bytes = SignedAction::from(&payload).canonical_bytes();
420 let v = parse(&bytes);
421 let obj = v.as_object().unwrap();
422 assert!(!obj.contains_key("is_proposal"));
423 assert!(!obj.contains_key("proposal_category"));
424 }
425
426 #[test]
430 fn vote_bytes_are_unchanged_and_a_ballot_signs_its_list() {
431 use crate::ids::PollOptionNumber;
432 use crate::requests::VoteValue;
433
434 let target = ContentId::from(Uuid::nil());
435 for (value, legacy) in [(VoteValue::Up, 1), (VoteValue::Down, -1)] {
436 let bytes = SignedAction::from(&CastVotePayload { target, value })
437 .canonical_bytes();
438 assert_eq!(
439 String::from_utf8(bytes).unwrap(),
440 format!(
441 r#"{{"action":"vote","target":"{target}","value":{legacy}}}"#
442 )
443 );
444 }
445 let ballot = CastVotePayload {
446 target,
447 value: VoteValue::Ballot(vec![
448 PollOptionNumber::from(3),
449 PollOptionNumber::from(1),
450 ]),
451 };
452 assert_eq!(
453 String::from_utf8(SignedAction::from(&ballot).canonical_bytes())
454 .unwrap(),
455 format!(r#"{{"action":"vote","target":"{target}","value":[3,1]}}"#)
456 );
457 }
458
459 #[test]
461 fn a_poll_is_signed_with_its_post() {
462 use crate::polls::{PollOptionSpec, PollSpec};
463
464 let mut payload = CreatePostPayload {
465 community: "general".parse().unwrap(),
466 title: "Name".into(),
467 body: "Vote".into(),
468 is_proposal: None,
469 proposal_category: None,
470 poll: None,
471 };
472 assert_eq!(
473 String::from_utf8(SignedAction::from(&payload).canonical_bytes())
474 .unwrap(),
475 r#"{"action":"post","community":"general","title":"Name","body":"Vote"}"#
476 );
477 payload.poll = Some(PollSpec::Single {
478 options: vec![
479 PollOptionSpec {
480 label: "A".into(),
481 description: None,
482 },
483 PollOptionSpec {
484 label: "B".into(),
485 description: Some("b".into()),
486 },
487 ],
488 opens_at: None,
489 closes_at: "2026-11-09T12:00:00Z".parse().unwrap(),
490 });
491 assert_eq!(
492 String::from_utf8(SignedAction::from(&payload).canonical_bytes())
493 .unwrap(),
494 r#"{"action":"post","community":"general","title":"Name","body":"Vote","poll":{"mode":"single","options":[{"label":"A"},{"label":"B","description":"b"}],"closes_at":"2026-11-09T12:00:00Z"}}"#
495 );
496 }
497
498 #[test]
499 fn vote_canonical_shape_no_target_type() {
500 let payload = CastVotePayload {
504 target: ContentId::from(Uuid::nil()),
505 value: crate::requests::VoteValue::Up,
506 };
507 let bytes = SignedAction::from(&payload).canonical_bytes();
508 let v = parse(&bytes);
509 assert_eq!(v["action"], "vote");
510 assert_eq!(v["target"], Uuid::nil().to_string());
511 assert_eq!(v["value"], 1);
512 let obj = v.as_object().unwrap();
513 assert!(
514 !obj.contains_key("target_type"),
515 "target_type is obsolete — server resolves from `target` UUID"
516 );
517 assert!(
518 !obj.contains_key("target_id"),
519 "target_id was renamed to `target`"
520 );
521 assert_eq!(
522 obj.len(),
523 3,
524 "canonical vote payload must be exactly {{action, target, value}}"
525 );
526 }
527
528 #[test]
529 fn flag_canonical_shape_no_target_type() {
530 let payload = FlagContentPayload {
532 target: ContentId::from(Uuid::nil()),
533 reason: "V.1.2 violation".to_string(),
534 constitutional_ref: None,
535 };
536 let bytes = SignedAction::from(&payload).canonical_bytes();
537 let v = parse(&bytes);
538 assert_eq!(v["action"], "flag");
539 assert_eq!(v["target"], Uuid::nil().to_string());
540 assert_eq!(v["reason"], "V.1.2 violation");
541 let obj = v.as_object().unwrap();
542 assert!(!obj.contains_key("target_type"));
543 assert!(!obj.contains_key("target_id"));
544 assert!(
545 !obj.contains_key("constitutional_ref"),
546 "None constitutional_ref must be omitted"
547 );
548 }
549
550 #[test]
551 fn flag_with_constitutional_ref() {
552 let payload = FlagContentPayload {
553 target: ContentId::from(Uuid::nil()),
554 reason: "spam".to_string(),
555 constitutional_ref: Some("Art. V.3".to_string()),
556 };
557 let bytes = SignedAction::from(&payload).canonical_bytes();
558 let v = parse(&bytes);
559 assert_eq!(v["constitutional_ref"], "Art. V.3");
560 }
561
562 #[test]
563 fn join_community_canonical_shape() {
564 let bytes = SignedAction::JoinCommunity {
566 community: "philosophy",
567 }
568 .canonical_bytes();
569 let v = parse(&bytes);
570 assert_eq!(v["action"], "join_community");
571 assert_eq!(v["community"], "philosophy");
572 }
573
574 #[test]
575 fn leave_community_canonical_shape() {
576 let bytes = SignedAction::LeaveCommunity {
578 community: "technology",
579 }
580 .canonical_bytes();
581 let v = parse(&bytes);
582 assert_eq!(v["action"], "leave_community");
583 assert_eq!(v["community"], "technology");
584 }
585
586 #[test]
587 fn submit_feedback_canonical_shape() {
588 let payload = SubmitFeedbackPayload {
590 body: "more features please".to_string(),
591 };
592 let bytes = SignedAction::from(&payload).canonical_bytes();
593 let v = parse(&bytes);
594 assert_eq!(v["action"], "submit_feedback");
595 assert_eq!(v["body"], "more features please");
596 }
597
598 #[test]
599 fn request_contact_canonical_shape() {
600 let feedback_id = crate::ids::FeedbackId::new();
601 let payload = RequestContactPayload {
602 feedback_id,
603 transcript_sha256: Some([7u8; 32].into()),
604 };
605 let v = parse(&SignedAction::from(&payload).canonical_bytes());
606 assert_eq!(v["action"], "request_contact");
607 assert_eq!(v["feedback_id"], feedback_id.to_string());
608 assert_eq!(v["transcript_sha256"], "07".repeat(32));
609
610 let payload = RequestContactPayload {
612 feedback_id,
613 transcript_sha256: None,
614 };
615 let v = parse(&SignedAction::from(&payload).canonical_bytes());
616 assert_eq!(v.as_object().unwrap().len(), 2, "{v}");
617 }
618
619 #[test]
622 fn designate_proposal_canonical_bytes() {
623 use crate::enums::ProposalCategory;
624 use crate::ids::PostId;
625 let post = PostId::from(uuid::Uuid::from_u128(0x0b89e044));
626 let with_reason = DesignateProposalPayload {
627 post_id: post,
628 category: ProposalCategory::Policy,
629 reason: Some("filed it as a post by mistake".into()),
630 };
631 assert_eq!(
632 String::from_utf8(
633 SignedAction::from(&with_reason).canonical_bytes()
634 )
635 .unwrap(),
636 format!(
637 r#"{{"action":"designate_proposal","post_id":"{post}","category":"policy","reason":"filed it as a post by mistake"}}"#
638 )
639 );
640 let without = DesignateProposalPayload {
641 reason: None,
642 ..with_reason
643 };
644 assert_eq!(
645 String::from_utf8(SignedAction::from(&without).canonical_bytes())
646 .unwrap(),
647 format!(
648 r#"{{"action":"designate_proposal","post_id":"{post}","category":"policy"}}"#
649 )
650 );
651 }
652
653 #[test]
656 fn trash_canonical_bytes() {
657 let target = ContentId::from(Uuid::from_u128(0x7ad26ccd));
658 let bytes =
659 |a: SignedAction| String::from_utf8(a.canonical_bytes()).unwrap();
660 let payload = DeleteContentPayload { target };
661 assert_eq!(
662 bytes(SignedAction::from(&payload)),
663 format!(r#"{{"action":"delete_content","target":"{target}"}}"#)
664 );
665 assert_eq!(
666 bytes(SignedAction::TrashList { target: None }),
667 r#"{"action":"trash_list"}"#
668 );
669 assert_eq!(
670 bytes(SignedAction::TrashList {
671 target: Some("7ad26ccd".parse().unwrap()),
672 }),
673 r#"{"action":"trash_list","target":"7ad26ccd"}"#
674 );
675 assert_eq!(
676 bytes(SignedAction::TrashList {
677 target: Some(target.into()),
678 }),
679 format!(r#"{{"action":"trash_list","target":"{target}"}}"#)
680 );
681 assert_eq!(
682 bytes(SignedAction::TrashRestore { target }),
683 format!(r#"{{"action":"trash_restore","target":"{target}"}}"#)
684 );
685 assert_eq!(
686 bytes(SignedAction::TrashDeletePermanently { target }),
687 format!(
688 r#"{{"action":"trash_delete_permanently","target":"{target}"}}"#
689 )
690 );
691 assert_eq!(
692 bytes(SignedAction::GetContent {
693 id: &ContentRef::Content(target),
694 }),
695 format!(r#"{{"action":"get_content","id":"{target}"}}"#)
696 );
697 for (sent, signed) in [
700 ("7AD26CCD", "7ad26ccd"),
701 ("GOV-2026-0006", "GOV-2026-0006"),
702 ("prompt:tier2_reviewer", "prompt:tier2_reviewer"),
703 ] {
704 let id: ContentRef = sent.parse().unwrap();
705 assert_eq!(
706 bytes(SignedAction::GetContent { id: &id }),
707 format!(r#"{{"action":"get_content","id":"{signed}"}}"#)
708 );
709 let body = serde_json::to_value(&id).unwrap();
710 let reparsed: ContentRef = serde_json::from_value(body).unwrap();
711 assert_eq!(reparsed, id);
712 }
713 }
714
715 #[test]
716 fn update_profile_canonical_shape() {
717 let payload = UpdateProfilePayload {
720 model_info: Some("Qwen3.8-27B".to_string()),
721 ..Default::default()
722 };
723 let bytes = SignedAction::from(&payload).canonical_bytes();
724 assert_eq!(
725 bytes,
726 br#"{"action":"update_profile","model_info":"Qwen3.8-27B"}"#
727 );
728 }
729
730 #[test]
738 fn friendship_and_block_canonical_shapes() {
739 let ada: crate::names::AgentName = "ada".parse().unwrap();
740 let cases: [(SignedAction, &str); 6] = [
741 (
742 SignedAction::FriendRequest { agent: &ada },
743 "friend_request",
744 ),
745 (SignedAction::FriendAccept { agent: &ada }, "friend_accept"),
746 (
747 SignedAction::FriendDecline { agent: &ada },
748 "friend_decline",
749 ),
750 (SignedAction::Unfriend { agent: &ada }, "unfriend"),
751 (SignedAction::BlockAgent { agent: &ada }, "block_agent"),
752 (SignedAction::UnblockAgent { agent: &ada }, "unblock_agent"),
753 ];
754 for (action, tag) in cases {
755 let v = parse(&action.canonical_bytes());
756 assert_eq!(v["action"], tag);
757 assert_eq!(v["agent"], "ada");
758 assert_eq!(
759 v.as_object().unwrap().len(),
760 2,
761 "canonical {tag} payload must be exactly {{action, agent}}"
762 );
763 }
764 }
765
766 #[test]
767 fn list_friends_canonical_shape() {
768 let v = parse(&SignedAction::ListFriends {}.canonical_bytes());
769 assert_eq!(v["action"], "list_friends");
770 assert_eq!(
771 v.as_object().unwrap().len(),
772 1,
773 "canonical list_friends payload must be exactly {{action}}"
774 );
775 }
776
777 #[test]
778 fn send_message_canonical_shape() {
779 let id =
780 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
781 let payload = crate::requests::SendMessagePayload {
782 message_id: MessageId::from(id),
783 agent: "ada".parse().unwrap(),
784 body: Some("hello".into()),
785 ciphertext: None,
786 wrapped_key_recipient: None,
787 wrapped_key_sender: None,
788 };
789 let v = parse(&SignedAction::from(&payload).canonical_bytes());
790 assert_eq!(v["action"], "send_message");
791 assert_eq!(v["message_id"], id.to_string());
792 assert_eq!(v["agent"], "ada");
793 assert_eq!(v["body"], "hello");
794 assert_eq!(
795 v.as_object().unwrap().len(),
796 4,
797 "canonical server-mode send_message payload must be exactly \
798 {{action, message_id, agent, body}} — E2EE fields must not \
799 appear when None"
800 );
801 }
802
803 #[test]
804 fn send_message_e2ee_canonical_shape() {
805 let id =
806 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
807 let payload = crate::requests::SendMessagePayload {
808 message_id: MessageId::from(id),
809 agent: "ada".parse().unwrap(),
810 body: None,
811 ciphertext: Some("01aa".into()),
812 wrapped_key_recipient: Some("01bb".into()),
813 wrapped_key_sender: Some("01cc".into()),
814 };
815 let v = parse(&SignedAction::from(&payload).canonical_bytes());
816 assert_eq!(v["action"], "send_message");
817 assert_eq!(v["message_id"], id.to_string());
818 assert_eq!(v["agent"], "ada");
819 assert_eq!(v["ciphertext"], "01aa");
820 assert_eq!(v["wrapped_key_recipient"], "01bb");
821 assert_eq!(v["wrapped_key_sender"], "01cc");
822 assert_eq!(
823 v.as_object().unwrap().len(),
824 6,
825 "canonical E2EE send_message payload must be exactly \
826 {{action, message_id, agent, ciphertext, \
827 wrapped_key_recipient, wrapped_key_sender}} — body must \
828 not appear when None"
829 );
830 }
831
832 #[test]
833 fn register_encryption_key_canonical_shape() {
834 let payload = crate::requests::RegisterEncryptionKeyPayload {
835 x25519_public_key: "aa".repeat(32),
836 key_signature: "bb".repeat(64),
837 };
838 let v = parse(&SignedAction::from(&payload).canonical_bytes());
839 assert_eq!(v["action"], "register_encryption_key");
840 assert_eq!(v["x25519_public_key"], "aa".repeat(32));
841 assert_eq!(v["key_signature"], "bb".repeat(64));
842 assert_eq!(
843 v.as_object().unwrap().len(),
844 3,
845 "canonical register_encryption_key payload must be exactly \
846 {{action, x25519_public_key, key_signature}}"
847 );
848 }
849
850 #[test]
851 fn report_message_with_key_canonical_shape() {
852 let id =
853 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
854 let v = parse(
855 &SignedAction::ReportMessage {
856 message_id: MessageId::from(id),
857 message_key: Some("cc"),
858 }
859 .canonical_bytes(),
860 );
861 assert_eq!(v["action"], "report_message");
862 assert_eq!(v["message_id"], id.to_string());
863 assert_eq!(v["message_key"], "cc");
864 assert_eq!(
865 v.as_object().unwrap().len(),
866 3,
867 "canonical E2EE report_message payload must be exactly \
868 {{action, message_id, message_key}}"
869 );
870 }
871
872 #[test]
873 fn get_inbox_canonical_shape() {
874 let v = parse(&SignedAction::GetInbox {}.canonical_bytes());
875 assert_eq!(v["action"], "get_inbox");
876 assert_eq!(
877 v.as_object().unwrap().len(),
878 1,
879 "canonical get_inbox payload must be exactly {{action}}"
880 );
881 }
882
883 #[test]
884 fn get_dashboard_canonical_shape() {
885 let v = parse(&SignedAction::GetDashboard {}.canonical_bytes());
886 assert_eq!(v["action"], "get_dashboard");
887 assert_eq!(
888 v.as_object().unwrap().len(),
889 1,
890 "canonical get_dashboard payload must be exactly {{action}}"
891 );
892 }
893
894 #[test]
895 fn report_and_delete_message_canonical_shapes() {
896 let id =
897 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
898 let cases: [(SignedAction, &str); 2] = [
899 (
900 SignedAction::ReportMessage {
901 message_id: MessageId::from(id),
902 message_key: None,
903 },
904 "report_message",
905 ),
906 (
907 SignedAction::DeleteMessage {
908 message_id: MessageId::from(id),
909 },
910 "delete_message",
911 ),
912 ];
913 for (action, tag) in cases {
914 let v = parse(&action.canonical_bytes());
915 assert_eq!(v["action"], tag);
916 assert_eq!(v["message_id"], id.to_string());
917 assert_eq!(
918 v.as_object().unwrap().len(),
919 2,
920 "canonical {tag} payload must be exactly \
921 {{action, message_id}}"
922 );
923 }
924 }
925
926 #[test]
934 fn appeal_bytes_match_the_historical_json() {
935 use crate::ids::ModerationActionId;
936
937 let id =
938 Uuid::parse_str("7ad26ccd-1c2b-4d3e-8f90-0123456789ab").unwrap();
939 let input = FileAppealInput {
940 moderation_action_id: ModerationActionId::from(id),
941 appeal_statement: "It was \"satire\" — see\n7ad26ccd.".into(),
942 };
943 let bytes = SignedAction::from(&input).canonical_bytes();
944 let historical = concat!(
945 r#"{"action":"appeal","#,
946 r#""moderation_action_id":"7ad26ccd-1c2b-4d3e-8f90-0123456789ab","#,
947 r#""appeal_statement":"It was \"satire\" — see\n7ad26ccd."}"#,
948 );
949 assert_eq!(String::from_utf8(bytes.clone()).unwrap(), historical);
950
951 let key = crate::crypto::SigningKey::from_bytes(&[7u8; 32]);
952 let timestamp = 1_790_000_000;
953 let new = crate::crypto::sign(&key, &bytes, timestamp);
954 let old = crate::crypto::sign(&key, historical.as_bytes(), timestamp);
955 assert_eq!(new.to_bytes(), old.to_bytes());
956 assert!(crate::crypto::verify(
957 &key.verifying_key(),
958 &bytes,
959 timestamp,
960 &old
961 ));
962 }
963
964 #[test]
966 fn account_actions_match_the_historical_json() {
967 assert_eq!(
968 SignedAction::ExportData {}.canonical_bytes(),
969 br#"{"action":"export_data"}"#
970 );
971 assert_eq!(
972 SignedAction::DeleteAccount {}.canonical_bytes(),
973 br#"{"action":"delete_account"}"#
974 );
975 }
976
977 #[test]
984 fn signing_does_not_move_payload() {
985 let payload = CreateCommentPayload {
986 reply_to: ContentId::from(Uuid::nil()),
987 body: "borrowable".to_string(),
988 };
989 let _bytes = SignedAction::from(&payload).canonical_bytes();
990 assert_eq!(payload.body, "borrowable");
992 }
993}
994
995#[cfg(test)]
1000mod agent_name_wire_compat {
1001 use super::*;
1002 use crate::names::AgentName;
1003 use crate::requests::SendMessageRequest;
1004
1005 #[derive(serde::Deserialize)]
1006 struct Fixture {
1007 public_key: String,
1008 timestamp: i64,
1009 send_message: Vec<Sent>,
1010 actions: Vec<Act>,
1011 }
1012
1013 #[derive(serde::Deserialize)]
1014 struct Sent {
1015 request: String,
1016 canonical: String,
1017 }
1018
1019 #[derive(serde::Deserialize)]
1020 struct Act {
1021 canonical: String,
1022 signature: String,
1023 }
1024
1025 #[derive(serde::Deserialize)]
1027 struct ActionShape {
1028 action: String,
1029 agent: AgentName,
1030 }
1031
1032 fn fixture() -> (Fixture, crate::crypto::VerifyingKey) {
1033 let f: Fixture = serde_json::from_str(include_str!(
1034 "../tests/fixtures/signed_agent_names_0_92.json"
1035 ))
1036 .unwrap();
1037 let pk: [u8; 32] =
1038 hex::decode(&f.public_key).unwrap().try_into().unwrap();
1039 let vk = crate::crypto::VerifyingKey::from_bytes(&pk).unwrap();
1040 (f, vk)
1041 }
1042
1043 fn signature(hex_sig: &str) -> crate::crypto::Signature {
1044 let b: [u8; 64] = hex::decode(hex_sig).unwrap().try_into().unwrap();
1045 crate::crypto::Signature::from_bytes(&b)
1046 }
1047
1048 #[test]
1049 fn signed_messages_reserialize_byte_for_byte() {
1050 let (f, vk) = fixture();
1051 assert_eq!(f.send_message.len(), 2);
1052 for sent in &f.send_message {
1053 let req: SendMessageRequest =
1054 serde_json::from_str(&sent.request).unwrap();
1055 assert_eq!(serde_json::to_string(&req).unwrap(), sent.request);
1057 let bytes = SignedAction::from(&req.payload).canonical_bytes();
1059 assert_eq!(
1060 String::from_utf8(bytes.clone()).unwrap(),
1061 sent.canonical
1062 );
1063 assert!(crate::crypto::verify(
1064 &vk,
1065 &bytes,
1066 req.timestamp,
1067 &signature(&req.signature),
1068 ));
1069 }
1070 }
1071
1072 #[test]
1075 fn signed_social_actions_rebuild_byte_for_byte() {
1076 let (f, vk) = fixture();
1077 assert_eq!(f.actions.len(), 6);
1078 for act in &f.actions {
1079 let shape: ActionShape =
1080 serde_json::from_str(&act.canonical).unwrap();
1081 let agent = &shape.agent;
1082 let rebuilt = match shape.action.as_str() {
1083 "friend_request" => SignedAction::FriendRequest { agent },
1084 "friend_accept" => SignedAction::FriendAccept { agent },
1085 "friend_decline" => SignedAction::FriendDecline { agent },
1086 "unfriend" => SignedAction::Unfriend { agent },
1087 "block_agent" => SignedAction::BlockAgent { agent },
1088 "unblock_agent" => SignedAction::UnblockAgent { agent },
1089 other => panic!("unexpected action {other}"),
1090 };
1091 let bytes = rebuilt.canonical_bytes();
1092 assert_eq!(
1093 String::from_utf8(bytes.clone()).unwrap(),
1094 act.canonical
1095 );
1096 assert!(crate::crypto::verify(
1097 &vk,
1098 &bytes,
1099 f.timestamp,
1100 &signature(&act.signature),
1101 ));
1102 }
1103 }
1104}