22import { ok , strictEqual } from "node:assert/strict" ;
33import { describe , test } from "node:test" ;
44import { MemoryKvStore } from "@fedify/fedify" ;
5- import { Follow , Person } from "@fedify/fedify/vocab" ;
5+ import { Accept , Follow , Person } from "@fedify/fedify/vocab" ;
66import { signRequest } from "@fedify/fedify/sig" ;
77import { LitePubRelay , MastodonRelay , type RelayOptions } from "@fedify/relay" ;
88import { createFederation } from "@fedify/testing" ;
@@ -676,7 +676,7 @@ describe("LitePubRelay", () => {
676676 ok ( handlerActor ) ;
677677 } ) ;
678678
679- test ( "stores follower in KV when Follow is approved " , async ( ) => {
679+ test ( "stores follower with pending state then accepted after two-step handshake " , async ( ) => {
680680 const kv = new MemoryKvStore ( ) ;
681681 const relay = new LitePubRelay ( {
682682 kv,
@@ -689,7 +689,6 @@ describe("LitePubRelay", () => {
689689 return await Promise . resolve ( true ) ;
690690 } ) ;
691691
692- // Manually simulate what happens when a Follow is approved in LitePub
693692 const follower = new Person ( {
694693 id : new URL ( "https://remote.example.com/users/alice" ) ,
695694 preferredUsername : "alice" ,
@@ -698,36 +697,46 @@ describe("LitePubRelay", () => {
698697
699698 const followerId = follower . id ! . href ;
700699
701- // Simulate the relay's internal logic - LitePub stores with actor ID and state
702- // First, Follow is received and stored with "pending" state (not added to followers list yet)
700+ // Step 1: Simulate Follow received and stored with "pending" state
703701 await kv . set ( [ "follower" , followerId ] , {
704702 actor : await follower . toJsonLd ( ) ,
705703 state : "pending" ,
706704 } ) ;
707705
708- // Then, Accept is received from the follower and state changes to "accepted"
706+ // Verify follower is in pending state
709707 const followerData = await kv . get < { actor : unknown ; state : string } > ( [
710708 "follower" ,
711709 followerId ,
712710 ] ) ;
711+ ok ( followerData ) ;
712+ strictEqual ( followerData . state , "pending" ) ;
713+
714+ // Verify follower is NOT in followers list yet
715+ let followers = await kv . get < string [ ] > ( [ "followers" ] ) ;
716+ ok ( ! followers || followers . length === 0 ) ;
717+
718+ // Step 2: Simulate Accept received - state changes to "accepted"
713719 if ( followerData ) {
714720 const updatedFollowerData = { ...followerData , state : "accepted" } ;
715721 await kv . set ( [ "follower" , followerId ] , updatedFollowerData ) ;
716722 }
717723
718724 // Now add to followers list
719- const followers = ( await kv . get < string [ ] > ( [ "followers" ] ) ) ?? [ ] ;
725+ followers = ( await kv . get < string [ ] > ( [ "followers" ] ) ) ?? [ ] ;
720726 followers . push ( followerId ) ;
721727 await kv . set ( [ "followers" ] , followers ) ;
722728
723- // Verify storage
729+ // Verify final state
724730 const storedFollowers = await kv . get < string [ ] > ( [ "followers" ] ) ;
725731 ok ( storedFollowers ) ;
726732 strictEqual ( storedFollowers . length , 1 ) ;
727733 strictEqual ( storedFollowers [ 0 ] , followerId ) ;
728734
729- const storedFollowerData = await kv . get ( [ "follower" , followerId ] ) ;
735+ const storedFollowerData = await kv . get < { actor : unknown ; state : string } > (
736+ [ "follower" , followerId ] ,
737+ ) ;
730738 ok ( storedFollowerData ) ;
739+ strictEqual ( storedFollowerData . state , "accepted" ) ;
731740 } ) ;
732741
733742 test ( "removes follower from KV when Undo Follow is received" , async ( ) => {
@@ -859,4 +868,261 @@ describe("LitePubRelay", () => {
859868 ok ( storedFollowers ) ;
860869 strictEqual ( storedFollowers . length , 3 ) ;
861870 } ) ;
871+
872+ test ( "Accept handler updates follower state and adds to followers list" , async ( ) => {
873+ const kv = new MemoryKvStore ( ) ;
874+ const relay = new LitePubRelay ( {
875+ kv,
876+ domain : "relay.example.com" ,
877+ documentLoaderFactory : ( ) => mockDocumentLoader ,
878+ authenticatedDocumentLoaderFactory : ( ) => mockDocumentLoader ,
879+ } ) ;
880+
881+ relay . setSubscriptionHandler ( async ( _ctx , _actor ) =>
882+ await Promise . resolve ( true )
883+ ) ;
884+
885+ const follower = new Person ( {
886+ id : new URL ( "https://remote.example.com/users/alice" ) ,
887+ preferredUsername : "alice" ,
888+ inbox : new URL ( "https://remote.example.com/users/alice/inbox" ) ,
889+ } ) ;
890+
891+ const followerId = follower . id ! . href ;
892+
893+ // Pre-populate with pending follower (simulating initial Follow was processed)
894+ await kv . set ( [ "follower" , followerId ] , {
895+ actor : await follower . toJsonLd ( ) ,
896+ state : "pending" ,
897+ } ) ;
898+
899+ // Verify not in followers list yet
900+ let followers = await kv . get < string [ ] > ( [ "followers" ] ) ;
901+ ok ( ! followers || followers . length === 0 ) ;
902+
903+ // Create Accept activity from the client
904+ const relayFollowActivity = new Follow ( {
905+ id : new URL ( "https://relay.example.com/activities/follow/1" ) ,
906+ actor : new URL ( "https://relay.example.com/users/relay" ) ,
907+ object : follower . id ,
908+ } ) ;
909+
910+ const acceptActivity = new Accept ( {
911+ id : new URL ( "https://remote.example.com/activities/accept/1" ) ,
912+ actor : follower . id ,
913+ object : relayFollowActivity ,
914+ } ) ;
915+
916+ // Sign and send the Accept activity to the relay's inbox
917+ let request = new Request ( "https://relay.example.com/inbox" , {
918+ method : "POST" ,
919+ headers : {
920+ "Content-Type" : "application/activity+json" ,
921+ "Accept" : "application/activity+json" ,
922+ } ,
923+ body : JSON . stringify (
924+ await acceptActivity . toJsonLd ( { contextLoader : mockDocumentLoader } ) ,
925+ ) ,
926+ } ) ;
927+
928+ request = await signRequest (
929+ request ,
930+ rsaKeyPair . privateKey ,
931+ rsaPublicKey . id ! ,
932+ ) ;
933+
934+ await relay . fetch ( request ) ;
935+
936+ // Verify state changed to accepted
937+ const updatedFollowerData = await kv . get < { actor : unknown ; state : string } > (
938+ [ "follower" , followerId ] ,
939+ ) ;
940+ ok ( updatedFollowerData ) ;
941+ strictEqual ( updatedFollowerData . state , "accepted" ) ;
942+
943+ // Verify added to followers list
944+ followers = await kv . get < string [ ] > ( [ "followers" ] ) ;
945+ ok ( followers ) ;
946+ strictEqual ( followers . length , 1 ) ;
947+ strictEqual ( followers [ 0 ] , followerId ) ;
948+ } ) ;
949+
950+ test ( "following dispatcher returns same actors as followers" , async ( ) => {
951+ const kv = new MemoryKvStore ( ) ;
952+
953+ // Pre-populate followers with accepted state
954+ const follower1 = new Person ( {
955+ id : new URL ( "https://remote1.example.com/users/alice" ) ,
956+ preferredUsername : "alice" ,
957+ inbox : new URL ( "https://remote1.example.com/users/alice/inbox" ) ,
958+ } ) ;
959+
960+ const follower2 = new Person ( {
961+ id : new URL ( "https://remote2.example.com/users/bob" ) ,
962+ preferredUsername : "bob" ,
963+ inbox : new URL ( "https://remote2.example.com/users/bob/inbox" ) ,
964+ } ) ;
965+
966+ const follower1Id = follower1 . id ! . href ;
967+ const follower2Id = follower2 . id ! . href ;
968+
969+ await kv . set ( [ "followers" ] , [ follower1Id , follower2Id ] ) ;
970+ await kv . set ( [ "follower" , follower1Id ] , {
971+ actor : await follower1 . toJsonLd ( ) ,
972+ state : "accepted" ,
973+ } ) ;
974+ await kv . set ( [ "follower" , follower2Id ] , {
975+ actor : await follower2 . toJsonLd ( ) ,
976+ state : "accepted" ,
977+ } ) ;
978+
979+ const relay = new LitePubRelay ( {
980+ kv,
981+ domain : "relay.example.com" ,
982+ documentLoaderFactory : ( ) => mockDocumentLoader ,
983+ } ) ;
984+
985+ // Fetch following collection
986+ const followingRequest = new Request (
987+ "https://relay.example.com/users/relay/following" ,
988+ {
989+ headers : { "Accept" : "application/activity+json" } ,
990+ } ,
991+ ) ;
992+ const followingResponse = await relay . fetch ( followingRequest ) ;
993+
994+ strictEqual ( followingResponse . status , 200 ) ;
995+ const followingJson = await followingResponse . json ( ) as any ;
996+ ok ( followingJson ) ;
997+ ok (
998+ followingJson . type === "Collection" ||
999+ followingJson . type === "OrderedCollection" ,
1000+ ) ;
1001+
1002+ // Fetch followers collection
1003+ const followersRequest = new Request (
1004+ "https://relay.example.com/users/relay/followers" ,
1005+ {
1006+ headers : { "Accept" : "application/activity+json" } ,
1007+ } ,
1008+ ) ;
1009+ const followersResponse = await relay . fetch ( followersRequest ) ;
1010+
1011+ strictEqual ( followersResponse . status , 200 ) ;
1012+ const followersJson = await followersResponse . json ( ) as any ;
1013+
1014+ // Verify both collections have same count
1015+ if ( followingJson . totalItems !== undefined ) {
1016+ strictEqual ( followingJson . totalItems , 2 ) ;
1017+ strictEqual ( followersJson . totalItems , 2 ) ;
1018+ }
1019+ } ) ;
1020+
1021+ test ( "pending followers are not in followers list" , async ( ) => {
1022+ const kv = new MemoryKvStore ( ) ;
1023+ const relay = new LitePubRelay ( {
1024+ kv,
1025+ domain : "relay.example.com" ,
1026+ documentLoaderFactory : ( ) => mockDocumentLoader ,
1027+ } ) ;
1028+
1029+ const follower = new Person ( {
1030+ id : new URL ( "https://remote.example.com/users/alice" ) ,
1031+ preferredUsername : "alice" ,
1032+ inbox : new URL ( "https://remote.example.com/users/alice/inbox" ) ,
1033+ } ) ;
1034+
1035+ const followerId = follower . id ! . href ;
1036+
1037+ // Store follower with pending state
1038+ await kv . set ( [ "follower" , followerId ] , {
1039+ actor : await follower . toJsonLd ( ) ,
1040+ state : "pending" ,
1041+ } ) ;
1042+
1043+ // Fetch followers collection
1044+ const request = new Request (
1045+ "https://relay.example.com/users/relay/followers" ,
1046+ {
1047+ headers : { "Accept" : "application/activity+json" } ,
1048+ } ,
1049+ ) ;
1050+ const response = await relay . fetch ( request ) ;
1051+
1052+ strictEqual ( response . status , 200 ) ;
1053+ const json = await response . json ( ) as any ;
1054+ ok ( json ) ;
1055+
1056+ // Verify pending follower is NOT in collection
1057+ if ( json . totalItems !== undefined ) {
1058+ strictEqual ( json . totalItems , 0 ) ;
1059+ }
1060+ } ) ;
1061+
1062+ test ( "duplicate Follow is ignored when follower is pending" , async ( ) => {
1063+ const kv = new MemoryKvStore ( ) ;
1064+ const relay = new LitePubRelay ( {
1065+ kv,
1066+ domain : "relay.example.com" ,
1067+ documentLoaderFactory : ( ) => mockDocumentLoader ,
1068+ authenticatedDocumentLoaderFactory : ( ) => mockDocumentLoader ,
1069+ } ) ;
1070+
1071+ let handlerCallCount = 0 ;
1072+
1073+ relay . setSubscriptionHandler ( async ( _ctx , _actor ) => {
1074+ handlerCallCount ++ ;
1075+ return await Promise . resolve ( true ) ;
1076+ } ) ;
1077+
1078+ const follower = new Person ( {
1079+ id : new URL ( "https://remote.example.com/users/alice" ) ,
1080+ preferredUsername : "alice" ,
1081+ inbox : new URL ( "https://remote.example.com/users/alice/inbox" ) ,
1082+ } ) ;
1083+
1084+ const followerId = follower . id ! . href ;
1085+
1086+ // Pre-populate with pending follower
1087+ await kv . set ( [ "follower" , followerId ] , {
1088+ actor : await follower . toJsonLd ( ) ,
1089+ state : "pending" ,
1090+ } ) ;
1091+
1092+ // Send another Follow activity from the same user
1093+ const followActivity = new Follow ( {
1094+ id : new URL ( "https://remote.example.com/activities/follow/2" ) ,
1095+ actor : follower . id ,
1096+ object : new URL ( "https://relay.example.com/users/relay" ) ,
1097+ } ) ;
1098+
1099+ let request = new Request ( "https://relay.example.com/inbox" , {
1100+ method : "POST" ,
1101+ headers : {
1102+ "Content-Type" : "application/activity+json" ,
1103+ "Accept" : "application/activity+json" ,
1104+ } ,
1105+ body : JSON . stringify (
1106+ await followActivity . toJsonLd ( { contextLoader : mockDocumentLoader } ) ,
1107+ ) ,
1108+ } ) ;
1109+
1110+ request = await signRequest (
1111+ request ,
1112+ rsaKeyPair . privateKey ,
1113+ rsaPublicKey . id ! ,
1114+ ) ;
1115+
1116+ await relay . fetch ( request ) ;
1117+
1118+ // Verify subscription handler was NOT called (duplicate was ignored)
1119+ strictEqual ( handlerCallCount , 0 ) ;
1120+
1121+ // Verify state is still pending
1122+ const followerData = await kv . get < { actor : unknown ; state : string } > (
1123+ [ "follower" , followerId ] ,
1124+ ) ;
1125+ ok ( followerData ) ;
1126+ strictEqual ( followerData . state , "pending" ) ;
1127+ } ) ;
8621128} ) ;
0 commit comments