feat(social): federated branch — authority + self-friend + idempotency

Adds direction-aware idempotency and already-friends checks to
handleFederatedFriendRequest (after stub hydration, before transaction),
plus 6 tests covering authority defense, bare-host normalization, cannot_friend_self,
already_friends, same-direction idempotent 200, and opposite-direction 409.
This commit is contained in:
Jannis Braun
2026-04-25 22:12:08 +02:00
parent 72f4b170f6
commit bf13e2a223
2 changed files with 191 additions and 0 deletions
@@ -293,3 +293,161 @@ describe('POST /api/social/requests — federated branch (lookup failures)', ()
expect(JSON.parse(res.body).error).toBe('invalid_target_domain'); expect(JSON.parse(res.body).error).toBe('invalid_target_domain');
}); });
}); });
describe('POST /api/social/requests — federated branch (authority + self-friend + idempotency)', () => {
beforeEach(() => {
resolveOriginFromHostnameMock.mockReturnValue('https://orbit.test');
ensurePeeredMock.mockResolvedValue({ status: 'active', peerId: 'p1' });
lookupRemoteUserMock.mockResolvedValue({
ok: true, homeUserId: 'remote-alice', username: 'alice',
profile: { displayName: 'Alice', avatar: null, avatarColor: 'mint', banner: null, bio: null },
});
});
it('returns 403 not_authoritative_for_sender when caller is a federated user', async () => {
seedSelf({ homeInstance: 'other.test', homeUserId: 'me-elsewhere' });
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).error).toBe('not_authoritative_for_sender');
});
it('passes authority check when sender homeInstance is stored as bare host', async () => {
// homeInstance stored without scheme — normalizeOriginForCompare('home.test') must
// equal normalizeOriginForCompare('https://home.test') (T1 invariant).
seedSelf({ homeInstance: 'home.test', homeUserId: CALLER_ID });
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(201);
});
it('returns 400 cannot_friend_self when looked-up user is canonical-self', async () => {
// The dispatcher routes this as federated because targetDomain ('otherhost') is not
// normalized to our host. resolveOriginFromHostname maps it to our own origin anyway
// (defense-in-depth: misconfigured or spoofed domain). The lookup returns our own
// canonical userId, triggering the self-friend check.
seedSelf();
resolveOriginFromHostnameMock.mockReturnValue('https://home.test');
lookupRemoteUserMock.mockResolvedValue({
ok: true, homeUserId: CALLER_ID, username: 'caller',
profile: { displayName: 'Caller', avatar: null, avatarColor: 'mint', banner: null, bio: null },
});
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'caller@otherhost' },
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toBe('cannot_friend_self');
});
it('returns 409 already_friends if friendship row exists', async () => {
seedSelf();
// Pre-seed stub and friendship
testDb.insert(schema.users).values({
id: 'stub-alice',
username: 'remote-alice@orbit.test',
displayName: 'Alice',
passwordHash: '',
status: 'offline',
isAdmin: 0,
homeInstance: 'orbit.test',
homeUserId: 'remote-alice',
createdAt: Date.now(),
}).run();
testDb.insert(schema.friends).values({
userId: CALLER_ID,
friendId: 'stub-alice',
createdAt: Date.now(),
}).run();
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(409);
expect(JSON.parse(res.body).error).toBe('already_friends');
});
it('returns 200 + existing requestId when same-direction request already pending (idempotent)', async () => {
seedSelf();
// Pre-seed stub and same-direction pending request
testDb.insert(schema.users).values({
id: 'stub-alice',
username: 'remote-alice@orbit.test',
displayName: 'Alice',
passwordHash: '',
status: 'offline',
isAdmin: 0,
homeInstance: 'orbit.test',
homeUserId: 'remote-alice',
createdAt: Date.now(),
}).run();
testDb.insert(schema.friendRequests).values({
id: 'existing-req',
fromId: CALLER_ID,
toId: 'stub-alice',
status: 'pending',
createdAt: Date.now(),
}).run();
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as { success: boolean; requestId: string };
expect(body.requestId).toBe('existing-req');
// No duplicate row created
const allRequests = testDb.select().from(schema.friendRequests).all();
expect(allRequests).toHaveLength(1);
});
it('returns 409 incoming_request_exists when opposite-direction request already pending', async () => {
seedSelf();
// Pre-seed stub and opposite-direction pending request
testDb.insert(schema.users).values({
id: 'stub-alice',
username: 'remote-alice@orbit.test',
displayName: 'Alice',
passwordHash: '',
status: 'offline',
isAdmin: 0,
homeInstance: 'orbit.test',
homeUserId: 'remote-alice',
createdAt: Date.now(),
}).run();
testDb.insert(schema.friendRequests).values({
id: 'incoming-req',
fromId: 'stub-alice',
toId: CALLER_ID,
status: 'pending',
createdAt: Date.now(),
}).run();
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
expect(res.statusCode).toBe(409);
const body = JSON.parse(res.body) as { error: string; requestId: string };
expect(body.error).toBe('incoming_request_exists');
expect(body.requestId).toBe('incoming-req');
});
});
+33
View File
@@ -217,6 +217,39 @@ async function handleFederatedFriendRequest(
} }
const stubHydrated = hydrateReplicatedUserProfile(stub, lookup.profile, db); const stubHydrated = hydrateReplicatedUserProfile(stub, lookup.profile, db);
// 5a. Direction-aware idempotency
const existingRequest = db.select().from(schema.friendRequests).where(and(
or(
and(eq(schema.friendRequests.fromId, sender.id), eq(schema.friendRequests.toId, stubHydrated.id)),
and(eq(schema.friendRequests.fromId, stubHydrated.id), eq(schema.friendRequests.toId, sender.id)),
),
eq(schema.friendRequests.status, 'pending'),
)).get();
if (existingRequest) {
if (existingRequest.fromId === sender.id) {
// Same direction — idempotent return
return reply.code(200).send({ success: true, requestId: existingRequest.id });
} else {
// Opposite direction — incoming request already exists
return reply.code(409).send({
error: 'incoming_request_exists',
statusCode: 409,
requestId: existingRequest.id,
});
}
}
// 5b. Already-friends check
const existingFriend = db.select().from(schema.friends).where(or(
and(eq(schema.friends.userId, sender.id), eq(schema.friends.friendId, stubHydrated.id)),
and(eq(schema.friends.userId, stubHydrated.id), eq(schema.friends.friendId, sender.id)),
)).get();
if (existingFriend) {
return reply.code(409).send({ error: 'already_friends', statusCode: 409 });
}
// 6. Transaction: insert + log + queue outbox // 6. Transaction: insert + log + queue outbox
const now = Date.now(); const now = Date.now();
const fromIdentity = { const fromIdentity = {