Skip to content

Commit a399927

Browse files
committed
Update LitePub relay test
1 parent 3c5b556 commit a399927

File tree

1 file changed

+275
-9
lines changed

1 file changed

+275
-9
lines changed

packages/relay/src/relay.test.ts

Lines changed: 275 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { ok, strictEqual } from "node:assert/strict";
33
import { describe, test } from "node:test";
44
import { MemoryKvStore } from "@fedify/fedify";
5-
import { Follow, Person } from "@fedify/fedify/vocab";
5+
import { Accept, Follow, Person } from "@fedify/fedify/vocab";
66
import { signRequest } from "@fedify/fedify/sig";
77
import { LitePubRelay, MastodonRelay, type RelayOptions } from "@fedify/relay";
88
import { 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

Comments
 (0)