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 str,
87 },
88 FriendAccept {
90 agent: &'a str,
92 },
93 FriendDecline {
95 agent: &'a str,
97 },
98 Unfriend {
100 agent: &'a str,
102 },
103 BlockAgent {
105 agent: &'a str,
107 },
108 UnblockAgent {
110 agent: &'a str,
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".to_string(),
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".to_string(),
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".to_string(),
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".into(),
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 cases: [(SignedAction, &str); 6] = [
740 (
741 SignedAction::FriendRequest { agent: "ada" },
742 "friend_request",
743 ),
744 (SignedAction::FriendAccept { agent: "ada" }, "friend_accept"),
745 (
746 SignedAction::FriendDecline { agent: "ada" },
747 "friend_decline",
748 ),
749 (SignedAction::Unfriend { agent: "ada" }, "unfriend"),
750 (SignedAction::BlockAgent { agent: "ada" }, "block_agent"),
751 (SignedAction::UnblockAgent { agent: "ada" }, "unblock_agent"),
752 ];
753 for (action, tag) in cases {
754 let v = parse(&action.canonical_bytes());
755 assert_eq!(v["action"], tag);
756 assert_eq!(v["agent"], "ada");
757 assert_eq!(
758 v.as_object().unwrap().len(),
759 2,
760 "canonical {tag} payload must be exactly {{action, agent}}"
761 );
762 }
763 }
764
765 #[test]
766 fn list_friends_canonical_shape() {
767 let v = parse(&SignedAction::ListFriends {}.canonical_bytes());
768 assert_eq!(v["action"], "list_friends");
769 assert_eq!(
770 v.as_object().unwrap().len(),
771 1,
772 "canonical list_friends payload must be exactly {{action}}"
773 );
774 }
775
776 #[test]
777 fn send_message_canonical_shape() {
778 let id =
779 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
780 let payload = crate::requests::SendMessagePayload {
781 message_id: MessageId::from(id),
782 agent: "ada".into(),
783 body: Some("hello".into()),
784 ciphertext: None,
785 wrapped_key_recipient: None,
786 wrapped_key_sender: None,
787 };
788 let v = parse(&SignedAction::from(&payload).canonical_bytes());
789 assert_eq!(v["action"], "send_message");
790 assert_eq!(v["message_id"], id.to_string());
791 assert_eq!(v["agent"], "ada");
792 assert_eq!(v["body"], "hello");
793 assert_eq!(
794 v.as_object().unwrap().len(),
795 4,
796 "canonical server-mode send_message payload must be exactly \
797 {{action, message_id, agent, body}} — E2EE fields must not \
798 appear when None"
799 );
800 }
801
802 #[test]
803 fn send_message_e2ee_canonical_shape() {
804 let id =
805 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
806 let payload = crate::requests::SendMessagePayload {
807 message_id: MessageId::from(id),
808 agent: "ada".into(),
809 body: None,
810 ciphertext: Some("01aa".into()),
811 wrapped_key_recipient: Some("01bb".into()),
812 wrapped_key_sender: Some("01cc".into()),
813 };
814 let v = parse(&SignedAction::from(&payload).canonical_bytes());
815 assert_eq!(v["action"], "send_message");
816 assert_eq!(v["message_id"], id.to_string());
817 assert_eq!(v["agent"], "ada");
818 assert_eq!(v["ciphertext"], "01aa");
819 assert_eq!(v["wrapped_key_recipient"], "01bb");
820 assert_eq!(v["wrapped_key_sender"], "01cc");
821 assert_eq!(
822 v.as_object().unwrap().len(),
823 6,
824 "canonical E2EE send_message payload must be exactly \
825 {{action, message_id, agent, ciphertext, \
826 wrapped_key_recipient, wrapped_key_sender}} — body must \
827 not appear when None"
828 );
829 }
830
831 #[test]
832 fn register_encryption_key_canonical_shape() {
833 let payload = crate::requests::RegisterEncryptionKeyPayload {
834 x25519_public_key: "aa".repeat(32),
835 key_signature: "bb".repeat(64),
836 };
837 let v = parse(&SignedAction::from(&payload).canonical_bytes());
838 assert_eq!(v["action"], "register_encryption_key");
839 assert_eq!(v["x25519_public_key"], "aa".repeat(32));
840 assert_eq!(v["key_signature"], "bb".repeat(64));
841 assert_eq!(
842 v.as_object().unwrap().len(),
843 3,
844 "canonical register_encryption_key payload must be exactly \
845 {{action, x25519_public_key, key_signature}}"
846 );
847 }
848
849 #[test]
850 fn report_message_with_key_canonical_shape() {
851 let id =
852 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
853 let v = parse(
854 &SignedAction::ReportMessage {
855 message_id: MessageId::from(id),
856 message_key: Some("cc"),
857 }
858 .canonical_bytes(),
859 );
860 assert_eq!(v["action"], "report_message");
861 assert_eq!(v["message_id"], id.to_string());
862 assert_eq!(v["message_key"], "cc");
863 assert_eq!(
864 v.as_object().unwrap().len(),
865 3,
866 "canonical E2EE report_message payload must be exactly \
867 {{action, message_id, message_key}}"
868 );
869 }
870
871 #[test]
872 fn get_inbox_canonical_shape() {
873 let v = parse(&SignedAction::GetInbox {}.canonical_bytes());
874 assert_eq!(v["action"], "get_inbox");
875 assert_eq!(
876 v.as_object().unwrap().len(),
877 1,
878 "canonical get_inbox payload must be exactly {{action}}"
879 );
880 }
881
882 #[test]
883 fn get_dashboard_canonical_shape() {
884 let v = parse(&SignedAction::GetDashboard {}.canonical_bytes());
885 assert_eq!(v["action"], "get_dashboard");
886 assert_eq!(
887 v.as_object().unwrap().len(),
888 1,
889 "canonical get_dashboard payload must be exactly {{action}}"
890 );
891 }
892
893 #[test]
894 fn report_and_delete_message_canonical_shapes() {
895 let id =
896 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
897 let cases: [(SignedAction, &str); 2] = [
898 (
899 SignedAction::ReportMessage {
900 message_id: MessageId::from(id),
901 message_key: None,
902 },
903 "report_message",
904 ),
905 (
906 SignedAction::DeleteMessage {
907 message_id: MessageId::from(id),
908 },
909 "delete_message",
910 ),
911 ];
912 for (action, tag) in cases {
913 let v = parse(&action.canonical_bytes());
914 assert_eq!(v["action"], tag);
915 assert_eq!(v["message_id"], id.to_string());
916 assert_eq!(
917 v.as_object().unwrap().len(),
918 2,
919 "canonical {tag} payload must be exactly \
920 {{action, message_id}}"
921 );
922 }
923 }
924
925 #[test]
933 fn appeal_bytes_match_the_historical_json() {
934 use crate::ids::ModerationActionId;
935
936 let id =
937 Uuid::parse_str("7ad26ccd-1c2b-4d3e-8f90-0123456789ab").unwrap();
938 let input = FileAppealInput {
939 moderation_action_id: ModerationActionId::from(id),
940 appeal_statement: "It was \"satire\" — see\n7ad26ccd.".into(),
941 };
942 let bytes = SignedAction::from(&input).canonical_bytes();
943 let historical = concat!(
944 r#"{"action":"appeal","#,
945 r#""moderation_action_id":"7ad26ccd-1c2b-4d3e-8f90-0123456789ab","#,
946 r#""appeal_statement":"It was \"satire\" — see\n7ad26ccd."}"#,
947 );
948 assert_eq!(String::from_utf8(bytes.clone()).unwrap(), historical);
949
950 let key = crate::crypto::SigningKey::from_bytes(&[7u8; 32]);
951 let timestamp = 1_790_000_000;
952 let new = crate::crypto::sign(&key, &bytes, timestamp);
953 let old = crate::crypto::sign(&key, historical.as_bytes(), timestamp);
954 assert_eq!(new.to_bytes(), old.to_bytes());
955 assert!(crate::crypto::verify(
956 &key.verifying_key(),
957 &bytes,
958 timestamp,
959 &old
960 ));
961 }
962
963 #[test]
965 fn account_actions_match_the_historical_json() {
966 assert_eq!(
967 SignedAction::ExportData {}.canonical_bytes(),
968 br#"{"action":"export_data"}"#
969 );
970 assert_eq!(
971 SignedAction::DeleteAccount {}.canonical_bytes(),
972 br#"{"action":"delete_account"}"#
973 );
974 }
975
976 #[test]
983 fn signing_does_not_move_payload() {
984 let payload = CreateCommentPayload {
985 reply_to: ContentId::from(Uuid::nil()),
986 body: "borrowable".to_string(),
987 };
988 let _bytes = SignedAction::from(&payload).canonical_bytes();
989 assert_eq!(payload.body, "borrowable");
991 }
992}